Compare commits

...
Author SHA1 Message Date
riccardom b786c3395a Drop the self-hosted caveat from the Debug Bundle Upload help text
The client no longer keeps the bundle on the peer when a self-hosted deployment
publishes no destination: netbirdio/netbird now falls back to the upload service
NetBird runs for every deployment, so the field's help text described behaviour
that is not there.
2026-09-11 12:44:57 +02:00
riccardom 3c009ddc0d Add the debug-bundle upload destination to the Clients settings
A debug bundle carries peer logs, routes, DNS and firewall state, and until now
every client path that uploads one without a human picking a destination sent it
to the service NetBird runs, with no way for a self-hosted operator to point it
elsewhere. netbirdio/netbird publishes the destination to peers from the account
settings; this exposes that setting.

Empty keeps the current behaviour for cloud accounts and, for a self-hosted
deployment, makes the peers keep the bundle locally instead. The URL is
validated as https-with-host on the client too, matching what the API accepts:
the peer fetches an upload URL from this endpoint and then PUTs the bundle to
whatever comes back, so a plaintext hop would expose both.
2026-09-09 09:38:55 +02:00
Maycon Santos 3c72444e83 Keep a refused provider save on screen, and its discovered prices (#772)
A refused save closed the modal anyway, throwing away the key just typed — and
the API never returns one. Both paths now stop before closing. The failure was
also reported twice and, because notify() only reddens through its promise path,
both times as a success.

Load and submit now spin, say what they are waiting on, and stop taking clicks;
dismissal is refused mid-save, since the write lands regardless and would
otherwise close a session reopened in the meantime.

Discovery carries the record id and the form's URL together, so changing an
endpoint no longer demands the key back. A 422 from it was reported as
unreachable rather than unsupported — useApiCall rejects with { code, message }
and the hook read status, leaving the catalog fallback unreachable.

Discovered models arrived at zero: 0/0 was hardcoded for anything the catalog
lacked by exact id, which stopped being true once discovery returned real rates.
Bedrock felt all of it — its geography-prefixed ids never match by string.

A Playwright spec covers the refusal end to end.
2026-09-02 21:47:47 +02:00
Nicolas Frati 386fb12013 fix(e2e): stabilize agentgateway provider test (#782) 2026-09-02 19:09:36 +02:00
Daneyon Hansen e3cc74522c feat(agent-network): add agentgateway provider support (#774)
* feat(agent-network): add agentgateway provider UI

Signed-off-by: Daneyon Hansen <daneyon.hansen@solo.io>

* fix(agent-network): address agentgateway review feedback

Signed-off-by: Daneyon Hansen <daneyon.hansen@solo.io>

* fix(agent-network): align with updated dashboard lint

Signed-off-by: Daneyon Hansen <daneyon.hansen@solo.io>

---------

Signed-off-by: Daneyon Hansen <daneyon.hansen@solo.io>
2026-09-02 01:01:13 +02:00
Maycon Santosandbraginini 1537d31f0d Add Agent Network Admin and Usage Viewer roles, gate Agent Network per submodule (#764)
* Add Agent Network Admin and Usage Viewer roles, gate Agent Network per submodule

The management server now ships two delegated roles (agent_network_admin,
usage_viewer) and reports agent_network submodule permissions
(agent_network.providers, .policies, .usage, .logs, .settings, ...) in
/users/current. The dashboard still gated the whole Agent Network section
on services.read, so the new roles saw either everything or nothing.

- register the agent_network module keys so PermissionsProvider fills
  denied defaults and typed lookups work
- gate the Agent Network sidebar and pages per submodule: providers,
  policies, usage (usage or logs read), configuration (settings)
- show the section when any child is permitted instead of services.read
- add both roles to the role selector, user/invite tables and badges

* Gate Usage and Clusters tabs by their own permissions

A caller holding only agent_network.logs.read landed on the Usage tab it
cannot read, and one holding only agent_network.usage.read could open
Access Logs. Show each tab only with its matching read permission and
fall back to the first permitted tab on deep links.

The Clusters tab is a reverse-proxy surface: its table and controls run
on the services permission, so agent_network.settings alone (e.g.
agent_network_admin) no longer surfaces it.

* Fall back to a known configuration tab for unknown query values

An unknown ?tab= value selected no content pane and left the
configuration page blank; unknown or unauthorized values now fall back
to Global Limits.

* Derive the configuration tab from the URL query

The ?tab= query was mirrored into local state through an effect, which
can lag a render behind the URL and duplicates the source of truth.
Derive the active tab from the query directly; trigger clicks already
push the query themselves, and onChange keeps pushing it for Radix's
keyboard navigation, which fires without a click.

* Derive the usage tab from the URL query

Same treatment as the configuration page: the ?tab= query was mirrored
into local state through an effect, duplicating the source of truth.
Derive the active tab from the query; onTabChange keeps pushing it so
clicks, deep links and back/forward all resolve the same way.

* Group user roles into General and Agent Network tabs

  Tab the role dropdown by product surface, gate the Agent Network
  roles and tab on the Agent Network surface being available, and
  disable the role field for the owner unless the caller is an owner.

* Point agent network learn-more links at their docs pages

* Skip the domains lookup when the role can't read it

* Flatten the role selector and add a caller-scoped My Setup page

The role dropdown split General and Agent Network roles into tabs,
which reads as two products inside one field. Return to the single
flat list from main, keeping the surface gate (Agent Network roles
hidden when the surface is unavailable) and the owner protections.

Regular users had no way to see which providers and models their
policies allow or where to point their tools. Add
/agent-network/my-setup backed by the caller-scoped me/setup and
me/consumption endpoints: the endpoint with copy, per-provider
available models, the Configure Your Agent instructions flattened
inline from the connect modal, and the caller's own usage counters.
The sidebar surfaces it for any role — limited view included —
whenever the caller's setup is configured, and stays hidden when
not, which is indistinguishable from no access by design.

* Fix delegated-role API errors and reuse the usage overview for My Usage

Field testing surfaced three problems. The policy and budget modals'
group selector fetched /networks/resources unconditionally, so an
agent_network_admin without the networks permission got a 403 and an
error toast on the policies page; gate the fetch on networks read.

Restricted users got no navigation at all, so the limited view could
never reach My Setup; render the sidebar for restricted users when
their setup is configured or their role reads an agent_network surface.
Roles below account admin also cannot read the account feature flag
(accounts read), so holding an explicit agent_network grant now counts
as proof the surface exists, while callers with accounts read keep the
flag as the source of truth.

My Usage rendered raw per-window counter rows, nothing like the admin
usage view; render it with the same chart-and-table component as the
admin overview, fed from the caller-scoped me/usage/overview endpoint
that returns the identical bucket shape filtered to the current user.

* Reuse the providers-page agent config on My Setup and self-scope Usage & Logs

My Setup now presents the endpoint exactly like the providers page — the
API Base URL badge with Copy and Agent Config opening the same modal
(provider ids come from the caller's me/setup answer instead of the
admin-permissioned provider context) — and drops the My Usage tab: the
regular Usage & Logs page now serves callers without the account-wide
grants too, showing their own data (the server pins the filters to them),
with the identity and provider filters trimmed since they'd be overridden.

usage_viewer gets working filters: the feature-flag fallback moves into
useAgentNetworkMode, AIProvidersProvider gates each fetch on its
submodule read grant so partially-granted roles don't fire 403s, and
GroupsProvider serves restricted roles that hold the groups read grant.
The limited view also gains the Peers link (the add-your-device page).

* Hide provider write actions from read-only viewers

usage_viewer now reads the provider list, but the page still offered every
write flow: Connect Provider, row-click edit, and the enable/disable/delete
menu. Clicking any of them opened the wizard, which - unable to read the
settings row - warned that Agent Network isn't set up even on configured
accounts. Each action is now gated on its own operation grant: the connect
button needs providers create, row-click edit needs update, and the menu
items need update/delete respectively (no menu at all without either).

* Restore the grouped role selector tabs

Bring back the General and Agent Network tabs in the role dropdown
from 3fbeb1e, undoing the flattening: the tabbed grouping keeps the
list readable as more surfaces ship their own delegated roles. The
surface gate (Agent Network roles hidden when the surface is
unavailable) and the owner protections are unchanged.

* Offer the provider and model filters to self-scoped callers

The providers endpoint now self-scopes on the server, so a configured
plain user fetches it too and the caller's own authorized providers back
the Provider and Model filter options on the self-scoped Usage & Logs
view. The identity filters stay hidden — the server pins user and group
to the caller regardless.

* Give the agent config a single home on Connect Agent

  The endpoint card carried an Agent Config action on both the providers
  page and the caller-scoped self-service page, so the same connect
  snippets had two entry points. EndpointBadge now renders it only when
  the caller passes providerIds, which is just the self-service page; the
  providers page keeps the URL and Copy. The context-backed
  AgentConnectModal wrapper is then unused, so it goes.

  Rename that page from My Setup to Connect Agent in the sidebar,
  breadcrumb and heading: My Setup was the only first-person label in the
  navigation, and Connect Agent stays clear of the Configuration sibling.

* Simplify Bedrock config

* Inline the agent config on the Connect page

  Drop the Agent Config modal: its snippets now render inline under the
  endpoint card, so AgentConnectModal is just AgentConnectTabs and
  EndpointBadge is back to the URL and Copy. Move the route to
  /agent-network/connect and swap the hand-rolled provider list for the
  standard DataTable. Follow the backend rename of me/setup to
  agent-config.

* Show a policy's allowed models in the policies table

  A policy's model restrictions live on the guardrails attached to it, so
  the new Models column unions the enabled model allowlists: one chip for
  a single model, a count with the names on hover past that, and a
  Restrict badge opening the guardrails tab when nothing narrows the
  policy. Clicking the column lands on that tab too.

  Chip the models on the Connect Agent table instead of comma-joining
  them, and capitalize the model counts across all three tables to match
  the "2 Groups" chips on Peers.

* Point the Kimi e2e config assertions at the Connect Agent page

The Kimi-gated config surfaces moved: the Agent Config modal is gone and
the snippets render inline on /agent-network/connect, so the spec stopped
finding the button it used to click on the providers page. Navigate to the
Connect page instead.

That page's answer is caller-scoped — it only offers providers the
caller's own policies authorize — so the spec now grants the owner the
freshly connected provider through a dedicated group + policy, torn down
again (group detached from the owner first) before and after the run. The
backing GET /agent-network/agent-config ships with newer management
builds; on builds that predate it the suite skips, same as the existing
catalog and settings-bootstrap probes.

* Hand the agent config to every account member

  The agent-config answer withheld the endpoint from callers no policy
  covers, so "account not set up" and "you have no access" were the same
  shape. The dashboard shows every user the same connection config, so
  Configured now tracks the account: once it has an endpoint every member
  gets it, with an empty providers list for those no policy covers. That
  list stays caller-scoped, and the endpoint authorizes nothing on its own
  since the gateway still refuses every request no policy permits.

  Rename the files and symbols the endpoint outgrew when it became
  /agent-network/agent-config: me_handler.go, setup.go and types/setup.go
  become agent_config.go, GetSetupForUser becomes GetAgentConfigForUser,
  and the AgentNetworkMeSetup schema becomes AgentNetworkAgentConfig.
  Regenerating types.gen.go from the untouched spec first reproduced it
  byte for byte, so the generated diff is only these renames.

* Let the surface switch decide the Agent Network section

  Connect Agent and Usage & Logs were visible on the caller's own setup
  alone, so a deployment with Agent Network off still showed them, and the
  route tree then 404d the links the nav had just rendered. The switch now
  decides first.

  The caller's agent config still stands in for the switch, but only for
  callers who cannot read the config themselves - otherwise an account
  holding an endpoint kept the surface on for its owner regardless.

* Collapse the two User types the merge left behind

  Both sides of the merge declared a User type, in different regions of the
  helper, so the merge took both without ever reporting a conflict and
  Playwright refused to parse the file. One type now carries the union of
  the fields the /users answer returns.

* Fix SNAPSHOT version display

* Fix e2e tests

* Fix e2e tests

* Fix version display

* Fix e2e

* Fix e2e

* Fix e2e

---------

Co-authored-by: braginini <bangvalo@gmail.com>
2026-09-01 14:35:18 +02:00
52 changed files with 2711 additions and 764 deletions

No files matched your search

+145 -13
View File
@@ -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);
}
});
});
+81 -18
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 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
View File
@@ -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
+1 -90
View File
@@ -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 &amp; 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&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"}
@@ -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>
+21 -10
View File
@@ -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>
+16
View File
@@ -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
+6 -1
View File
@@ -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;
+56 -17
View File
@@ -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>
+6 -2
View File
@@ -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>
+8
View File
@@ -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 = {
+7 -2
View File
@@ -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();
+7 -3
View File
@@ -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 };
+1
View File
@@ -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