mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b786c3395a | ||
|
|
3c009ddc0d | ||
|
|
3c72444e83 | ||
|
|
386fb12013 | ||
|
|
e3cc74522c | ||
|
|
1537d31f0d |
No files matched your search
+145
-13
@@ -46,8 +46,7 @@ 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}`;
|
||||
@@ -76,15 +75,35 @@ async function apiDelete(page: Page, path: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function apiPost<T>(page: Page, path: string, data: unknown): Promise<T> {
|
||||
async function apiPost<T>(page: Page, path: string, body: unknown): Promise<T> {
|
||||
const { token, origin } = await getApiContext(page);
|
||||
const resp = await page.request.post(`${origin}/api${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data,
|
||||
data: body,
|
||||
});
|
||||
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");
|
||||
}
|
||||
@@ -108,10 +127,12 @@ 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}`);
|
||||
}
|
||||
@@ -259,7 +280,10 @@ 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) {
|
||||
@@ -338,8 +362,13 @@ 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) {
|
||||
@@ -353,7 +382,10 @@ 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) {
|
||||
@@ -368,7 +400,9 @@ 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");
|
||||
}
|
||||
|
||||
@@ -376,7 +410,10 @@ 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) {
|
||||
@@ -391,11 +428,16 @@ 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}`);
|
||||
}
|
||||
|
||||
@@ -449,7 +491,13 @@ 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,
|
||||
})),
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -461,6 +509,8 @@ type User = {
|
||||
name: string;
|
||||
role: string;
|
||||
status: string;
|
||||
auto_groups: string[];
|
||||
is_blocked: boolean;
|
||||
is_current: boolean;
|
||||
};
|
||||
|
||||
@@ -538,3 +588,85 @@ 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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 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 });
|
||||
|
||||
const upstreamURL = "https://agentgateway.e2e.example";
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 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.
|
||||
* 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.
|
||||
*
|
||||
* 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,20 +19,57 @@
|
||||
* localStorage override (see testAgentNetworkOverride in utils/netbird.ts),
|
||||
* set via addInitScript on a dedicated context below.
|
||||
*/
|
||||
import { test, expect, type Browser, type Page } from "@playwright/test";
|
||||
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 { 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>;
|
||||
@@ -71,8 +108,17 @@ 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 deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
|
||||
await cleanupKimiFixtures(page);
|
||||
|
||||
await page.goto("/agent-network/providers");
|
||||
await page.keyboard.press("Escape");
|
||||
@@ -153,17 +199,36 @@ 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 ----
|
||||
await page
|
||||
.getByRole("button", { name: "Agent Config" })
|
||||
.click({ force: true });
|
||||
// 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],
|
||||
});
|
||||
|
||||
// Kimi CLI tab only renders when a kimi_api provider is connected.
|
||||
await expect(page.getByRole("tab", { name: "Kimi CLI" })).toBeVisible();
|
||||
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,
|
||||
});
|
||||
|
||||
// Claude Code tab's backend dropdown offers (and, with Kimi as the only
|
||||
// Anthropic-shaped provider, pre-selects) Kimi — its settings.json
|
||||
@@ -192,10 +257,8 @@ 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 deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
|
||||
await cleanupKimiFixtures(page);
|
||||
} 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
+93
-12
@@ -13,12 +13,80 @@ 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" });
|
||||
@@ -36,20 +104,33 @@ async function loginAndSave(
|
||||
const modal = page.getByTestId("setup-netbird-modal");
|
||||
const approval = page.getByText("User Approval Pending");
|
||||
|
||||
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),
|
||||
]);
|
||||
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;
|
||||
}
|
||||
|
||||
if (after_login === "2fa") {
|
||||
await skipButton.click();
|
||||
await Promise.race([
|
||||
appNav.waitFor({ timeout: 15_000 }),
|
||||
modal.waitFor({ timeout: 15_000 }),
|
||||
approval.waitFor({ timeout: 15_000 }),
|
||||
]);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss setup modal if present
|
||||
@@ -105,7 +186,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
|
||||
|
||||
Generated
+1
-90
@@ -1065,9 +1065,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1084,9 +1081,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1103,9 +1097,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1122,9 +1113,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1141,9 +1129,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1160,9 +1145,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1179,9 +1161,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1198,9 +1177,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1217,9 +1193,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1242,9 +1215,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1267,9 +1237,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1292,9 +1259,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1317,9 +1281,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1342,9 +1303,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1367,9 +1325,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1392,9 +1347,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1623,9 +1575,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1642,9 +1591,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1661,9 +1607,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1680,9 +1623,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3098,9 +3038,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3118,9 +3055,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3138,9 +3072,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3158,9 +3089,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3178,9 +3106,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3198,9 +3123,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8038,9 +7960,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8062,9 +7981,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8086,9 +8002,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8110,9 +8023,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8872,6 +8782,7 @@
|
||||
"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 { useSearchParams } from "next/navigation";
|
||||
import React, { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { lazy, Suspense, useMemo } from "react";
|
||||
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
|
||||
import GroupsProvider from "@/contexts/GroupsProvider";
|
||||
import PeersProvider from "@/contexts/PeersProvider";
|
||||
@@ -38,15 +38,32 @@ export default function AgentNetworkConfigurationPage() {
|
||||
const { only: agentNetworkOnly } = useAgentNetworkMode();
|
||||
const queryParams = useSearchParams();
|
||||
const queryTab = queryParams.get("tab");
|
||||
const [tab, setTab] = useState(queryTab ?? TAB_BUDGET_SETTINGS);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (queryTab) setTab(queryTab);
|
||||
}, [queryTab]);
|
||||
// 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 });
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<VerticalTabs value={tab} onChange={setTab}>
|
||||
<VerticalTabs value={tab} onChange={onTabChange}>
|
||||
<VerticalTabs.List>
|
||||
<VerticalTabs.Trigger value={TAB_BUDGET_SETTINGS}>
|
||||
<Gauge size={14} />
|
||||
@@ -56,14 +73,16 @@ export default function AgentNetworkConfigurationPage() {
|
||||
<ScrollText size={14} />
|
||||
Log Collection
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value={TAB_CLUSTERS}>
|
||||
<ServerIcon size={14} />
|
||||
Clusters
|
||||
</VerticalTabs.Trigger>
|
||||
{canReadClusters && (
|
||||
<VerticalTabs.Trigger value={TAB_CLUSTERS}>
|
||||
<ServerIcon size={14} />
|
||||
Clusters
|
||||
</VerticalTabs.Trigger>
|
||||
)}
|
||||
</VerticalTabs.List>
|
||||
<RestrictedAccess
|
||||
page={"Configuration"}
|
||||
hasAccess={permission?.services?.read}
|
||||
hasAccess={permission?.["agent_network.settings"]?.read}
|
||||
>
|
||||
<GroupsProvider>
|
||||
<PeersProvider>
|
||||
@@ -96,26 +115,28 @@ export default function AgentNetworkConfigurationPage() {
|
||||
</Suspense>
|
||||
</Tabs.Content>
|
||||
|
||||
<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"}
|
||||
{canReadClusters && (
|
||||
<Tabs.Content value={TAB_CLUSTERS} className={"w-full"}>
|
||||
<ConfigTabHeader
|
||||
label={"Clusters"}
|
||||
href={"/agent-network/configuration?tab=clusters"}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</ConfigTabHeader>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<ClustersTable />
|
||||
</Suspense>
|
||||
</Tabs.Content>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</AIProvidersProvider>
|
||||
</PeersProvider>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"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 & Models</h2>
|
||||
<ConnectProvidersTable providers={providers} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export default function AgentNetworkPoliciesPage() {
|
||||
|
||||
<RestrictedAccess
|
||||
page={"Policies"}
|
||||
hasAccess={permission?.services?.read}
|
||||
hasAccess={permission?.["agent_network.policies"]?.read}
|
||||
>
|
||||
<AIProvidersProvider>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
|
||||
@@ -6,93 +6,28 @@ import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useCopyToClipboard from "@hooks/useCopyToClipboard";
|
||||
import { Copy, ExternalLinkIcon, Globe, Plug } from "lucide-react";
|
||||
import React, { Suspense, useState } from "react";
|
||||
import { ExternalLinkIcon, Globe } from "lucide-react";
|
||||
import React, { Suspense } from "react";
|
||||
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import 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's
|
||||
<code className={"font-mono"}> base_url</code>, Anthropic'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"}
|
||||
@@ -168,10 +103,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
|
||||
services.read. */}
|
||||
read on the providers submodule. */}
|
||||
<RestrictedAccess
|
||||
page={"Providers"}
|
||||
hasAccess={permission?.services?.read}
|
||||
hasAccess={permission?.["agent_network.providers"]?.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, useEffect, useMemo, useState } from "react";
|
||||
import React, { Suspense, useMemo, useState } from "react";
|
||||
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
|
||||
import GroupsProvider from "@/contexts/GroupsProvider";
|
||||
import PeersProvider from "@/contexts/PeersProvider";
|
||||
@@ -18,14 +18,13 @@ 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
|
||||
@@ -36,9 +35,31 @@ 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 initialTab = VALID_TABS.has(queryTab) ? queryTab : TAB_USAGE;
|
||||
const [tab, setTab] = useState(initialTab);
|
||||
const tab = allowedTabs.has(queryTab) ? queryTab : defaultTab;
|
||||
|
||||
// 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
|
||||
@@ -55,16 +76,8 @@ 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 });
|
||||
@@ -94,7 +107,7 @@ export default function UsageAndLogsPage() {
|
||||
|
||||
<RestrictedAccess
|
||||
page={"Usage & Logs"}
|
||||
hasAccess={permission?.services?.read}
|
||||
hasAccess={canUseUsage || canUseLogs}
|
||||
>
|
||||
<GroupsProvider>
|
||||
<PeersProvider>
|
||||
@@ -102,45 +115,53 @@ export default function UsageAndLogsPage() {
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={onTabChange}
|
||||
defaultValue={initialTab}
|
||||
className={"pt-4 pb-0 mb-0"}
|
||||
>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
<TabsTrigger value={TAB_USAGE}>
|
||||
<LayoutDashboard size={16} />
|
||||
Usage
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={TAB_ACCESS_LOGS}>
|
||||
<ScrollText size={16} />
|
||||
Access Logs
|
||||
</TabsTrigger>
|
||||
{canUseUsage && (
|
||||
<TabsTrigger value={TAB_USAGE}>
|
||||
<LayoutDashboard size={16} />
|
||||
Usage
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canUseLogs && (
|
||||
<TabsTrigger value={TAB_ACCESS_LOGS}>
|
||||
<ScrollText size={16} />
|
||||
Access Logs
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={TAB_USAGE} className={"pb-8"}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<AgentOverviewPanel />
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
{canUseUsage && (
|
||||
<TabsContent value={TAB_USAGE} className={"pb-8"}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<AgentOverviewPanel selfScoped={!canReadUsage} />
|
||||
</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>
|
||||
{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>
|
||||
)}
|
||||
</Tabs>
|
||||
</AIProvidersProvider>
|
||||
</PeersProvider>
|
||||
|
||||
@@ -93,10 +93,15 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
const userRequest = useApiCall<User>("/users");
|
||||
const isServiceUser = !!user?.is_service_user;
|
||||
const { mutate } = useSWRConfig();
|
||||
const { loggedInUser, isOwnerOrAdmin, isUser } = useLoggedInUser();
|
||||
const { loggedInUser, isOwner, 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,
|
||||
@@ -250,20 +255,26 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<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"}>
|
||||
<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]"}>
|
||||
<UserRoleSelector
|
||||
value={role}
|
||||
onChange={setRole}
|
||||
hideOwner={isServiceUser}
|
||||
currentUser={user}
|
||||
disabled={isLoggedInUser || !permission.users.update}
|
||||
disabled={
|
||||
isLoggedInUser ||
|
||||
!permission.users.update ||
|
||||
cannotChangeOwnerRole
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useSearch } from "@hooks/useSearch";
|
||||
import useSortedDropdownOptions from "@hooks/useSortedDropdownOptions";
|
||||
import { IconArrowBack } from "@tabler/icons-react";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Command, CommandGroup, CommandInput, CommandList } from "cmdk";
|
||||
import { sortBy, trim, unionBy } from "lodash";
|
||||
@@ -203,9 +204,13 @@ export function PeerGroupSelector({
|
||||
selectedCluster,
|
||||
onClusterChange,
|
||||
}: Readonly<MultiSelectProps>) {
|
||||
// Network resources sit behind the networks permission; callers without
|
||||
// it (e.g. agent_network_admin opening the policy modal) get a plain
|
||||
// group selector instead of a 403.
|
||||
const { permission } = usePermissions();
|
||||
const { data: fetchedResources, isLoading: isResourcesLoading } = useFetchApi<
|
||||
NetworkResource[]
|
||||
>("/networks/resources");
|
||||
>("/networks/resources", false, true, !!permission?.networks?.read);
|
||||
|
||||
const resources = useMemo(() => {
|
||||
if (!additionalResources?.length) return fetchedResources;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { SmallBadge } from "@components/ui/SmallBadge";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ArrowUpCircle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -18,14 +19,55 @@ function formatVersion(version: string): string {
|
||||
return version;
|
||||
}
|
||||
|
||||
// A pre-release label names the release itself, so it stays in the short form.
|
||||
// Anything else after the release names the build and is dropped.
|
||||
const PRERELEASE_LABEL = /^(rc|alpha|beta)[\w.]*$/i;
|
||||
|
||||
// A goreleaser snapshot is built from an unreleased tree and versioned as the
|
||||
// NEXT release ("0.77.1-SNAPSHOT-a1b2c3d" is built after 0.77.0 shipped), so
|
||||
// showing the number alone would name a release this build is not. The number
|
||||
// still gets shortened — the commit is what overflows — and the badge beside
|
||||
// it says which kind of build it came from.
|
||||
const SNAPSHOT_SUFFIX = /-snapshot\b/i;
|
||||
|
||||
function isSnapshotVersion(version: string): boolean {
|
||||
return SNAPSHOT_SUFFIX.test(version);
|
||||
}
|
||||
|
||||
// Builds can carry a suffix that overflows the sidebar: semver build metadata
|
||||
// ("0.77.0+enterprise.1" on enterprise builds) or a numeric CI build number
|
||||
// ("0.76.3-31256681241"). Show the release only and keep the full string for the
|
||||
// tooltip. Pre-release labels like "-rc.1" are left intact so they stay visible.
|
||||
// ("0.77.0+enterprise.1" on enterprise builds), a numeric CI build number
|
||||
// ("0.76.3-31256681241"), or a goreleaser snapshot tag
|
||||
// ("0.60.1-SNAPSHOT-a1b2c3d"). Show the release only and keep the full string
|
||||
// for the tooltip. A version that is not a release at all ("development",
|
||||
// "ci-7470fbdd") has nothing to shorten and passes through as it is.
|
||||
function formatShortVersion(version: string): string {
|
||||
return formatVersion(version)
|
||||
.replace(/\+.*$/, "")
|
||||
.replace(/^(v?\d+(?:\.\d+)*)-\d+$/, "$1");
|
||||
const formatted = formatVersion(version).replace(/\+.*$/, "");
|
||||
const release = /^(v?\d+(?:\.\d+)*)(?:-(.+))?$/.exec(formatted);
|
||||
if (!release) return formatted;
|
||||
const [, numbers, suffix] = release;
|
||||
return suffix && PRERELEASE_LABEL.test(suffix)
|
||||
? `${numbers}-${suffix}`
|
||||
: numbers;
|
||||
}
|
||||
|
||||
// The right-hand side of a row: the shortened number, plus a marker when the
|
||||
// build is not the release that number names.
|
||||
function VersionValue({ version }: { version: string }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-nb-gray-300 font-medium truncate">
|
||||
{formatShortVersion(version)}
|
||||
</span>
|
||||
{isSnapshotVersion(version) && (
|
||||
<SmallBadge
|
||||
text={"SNAPSHOT"}
|
||||
variant={"yellow"}
|
||||
size={"md"}
|
||||
className={"shrink-0"}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const NavigationVersionInfo = () => {
|
||||
@@ -96,18 +138,17 @@ const NavigationVersionInfoContent = () => {
|
||||
{formatVersion(versionInfo.management_current_version)}
|
||||
</span>
|
||||
<span>
|
||||
Latest: {formatVersion(versionInfo.management_available_version)}
|
||||
Latest:{" "}
|
||||
{formatVersion(versionInfo.management_available_version)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
side="top"
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full cursor-default">
|
||||
<span>Management</span>
|
||||
<span className="text-nb-gray-300 font-medium">
|
||||
{formatShortVersion(versionInfo.management_current_version)}
|
||||
</span>
|
||||
<div className="flex items-center justify-between w-full cursor-default gap-2">
|
||||
<span className="shrink-0">Management</span>
|
||||
<VersionValue version={versionInfo.management_current_version} />
|
||||
</div>
|
||||
</FullTooltip>
|
||||
<FullTooltip
|
||||
@@ -122,11 +163,9 @@ const NavigationVersionInfoContent = () => {
|
||||
side="top"
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full cursor-default">
|
||||
<span>Dashboard</span>
|
||||
<span className="text-nb-gray-300 font-medium">
|
||||
{formatShortVersion(dashboardVersion)}
|
||||
</span>
|
||||
<div className="flex items-center justify-between w-full cursor-default gap-2">
|
||||
<span className="shrink-0">Dashboard</span>
|
||||
<VersionValue version={dashboardVersion} />
|
||||
</div>
|
||||
</FullTooltip>
|
||||
</div>
|
||||
|
||||
@@ -25,9 +25,13 @@ const GroupContext = React.createContext(
|
||||
);
|
||||
|
||||
export default function GroupsProvider({ children }: Props) {
|
||||
const { isRestricted } = usePermissions();
|
||||
const { isRestricted, permission } = usePermissions();
|
||||
|
||||
return isRestricted ? (
|
||||
// Restricted users normally get the inert shell, but roles that are
|
||||
// restricted yet hold the groups read grant (usage_viewer, which needs
|
||||
// group names for the usage filters) still get the real provider — the
|
||||
// content gates its fetch on that same grant.
|
||||
return isRestricted && !permission.groups.read ? (
|
||||
<>{children}</>
|
||||
) : (
|
||||
<GroupsProviderContent>{children}</GroupsProviderContent>
|
||||
|
||||
@@ -39,6 +39,14 @@ const MODULE_KEYS: Array<keyof Permissions["modules"]> = [
|
||||
"proxy",
|
||||
"proxy_configuration",
|
||||
"services",
|
||||
"agent_network",
|
||||
"agent_network.providers",
|
||||
"agent_network.policies",
|
||||
"agent_network.guardrails",
|
||||
"agent_network.budgets",
|
||||
"agent_network.usage",
|
||||
"agent_network.logs",
|
||||
"agent_network.settings",
|
||||
];
|
||||
|
||||
const DENIED: Permission = {
|
||||
|
||||
@@ -28,16 +28,21 @@ const UserProfileContext = React.createContext(
|
||||
);
|
||||
|
||||
export default function UsersProvider({ children }: Readonly<Props>) {
|
||||
// Mounted on every dashboard page, above PermissionsProvider — so the
|
||||
// caller's grants aren't known here and the request goes out for roles
|
||||
// that can't list users (agent_network_admin, usage_viewer). Errors are
|
||||
// ignored so their 403 leaves the list undefined, which every consumer
|
||||
// already handles, instead of raising the red toast on every page load.
|
||||
const {
|
||||
data: users,
|
||||
mutate,
|
||||
isLoading,
|
||||
} = useFetchApi<User[]>("/users?service_user=false");
|
||||
} = useFetchApi<User[]>("/users?service_user=false", true);
|
||||
const {
|
||||
data: serviceUsers,
|
||||
mutate: mutateServiceUsers,
|
||||
isLoading: isLoadingServiceUsers,
|
||||
} = useFetchApi<User[]>("/users?service_user=true");
|
||||
} = useFetchApi<User[]>("/users?service_user=true", true);
|
||||
|
||||
const refresh = () => {
|
||||
mutate().then();
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { useAccount } from "@/modules/account/useAccount";
|
||||
import { SSOIdentityProvider } from "@/interfaces/IdentityProvider";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { SSOIdentityProvider } from "@/interfaces/IdentityProvider";
|
||||
import { useAccount } from "@/modules/account/useAccount";
|
||||
|
||||
export function useEmbeddedIdentityProviders() {
|
||||
const account = useAccount();
|
||||
const { permission } = usePermissions();
|
||||
const isEmbeddedIdPEnabled = !!account?.settings?.embedded_idp_enabled;
|
||||
|
||||
// The listing needs the identity_providers grant; without it the call is
|
||||
// a guaranteed 403, so gate on the grant as well as the account setting.
|
||||
const { data: providers } = useFetchApi<SSOIdentityProvider[]>(
|
||||
"/identity-providers",
|
||||
true,
|
||||
true,
|
||||
isEmbeddedIdPEnabled,
|
||||
isEmbeddedIdPEnabled && permission.identity_providers.read,
|
||||
);
|
||||
|
||||
return { providers, isEmbeddedIdPEnabled };
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface Account {
|
||||
local_mfa_enabled?: boolean;
|
||||
ipv6_enabled_groups?: string[];
|
||||
network_range_v6?: string;
|
||||
debug_bundle_upload_url?: string;
|
||||
agent_network_only?: boolean;
|
||||
dashboard_features?: AccountDashboardFeatures;
|
||||
};
|
||||
|
||||
Loaded 20 of 52 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user