Compare commits

...
16 Commits
Author SHA1 Message Date
Misha Bragin 49582fff74 Trim numeric build suffix from sidebar version display. (#749)
Self-hosted management versions can carry a numeric build suffix
  (e.g. 0.76.3-31256681241) that overflows the sidebar box. Show the
  semver only and move the full string into the hover tooltip alongside
  the latest version. Pre-release labels like -rc.1 are preserved.
2026-08-09 19:20:49 +02:00
Jack Carter af7745a03b chore: remove solutions engineer booking modal from onboarding (#745)
The onboarding survey opened a HubSpot booking modal for accounts with
50+ planned users. Removes the modal, its onboarding-call experiment
variants, the trigger in Onboarding.tsx, and the now-unused avatar.

Keeps src/cloud/cloud-hooks/useExperiment.ts, which this was the only
consumer of, for a future experiment.
2026-08-06 10:48:31 +02:00
Maycon Santos 19d82cc2ab Handle agent-network settings defaults response (#739)
* Handle agent-network settings defaults response

The management API now answers the settings GET with a defaults object
before the account is bootstrapped (empty cluster/subdomain/endpoint)
instead of a JSON null body. Normalize that shape to the existing null
settings signal so every consumer keeps a single not-bootstrapped check,
while still tolerating the null body and 404 from older backends.

* Pin the settings wire-shape normalization in a Playwright spec

The providers page must render the connect-first empty state for every
unbootstrapped settings shape the management API has answered with over
time (defaults object with an empty endpoint, JSON null body, 404) and
the endpoint badge once bootstrapped. The settings route is mocked per
test so the spec is independent of the backend build and of account
state left behind by other suites.

* Address review: no stale settings on 404, close context on setup failure

useFetchApi keeps the previous SWR data alongside the error, so a later
404 could expose stale settings through the memo; it now yields null
when the error is a 404 while still riding out transient failures on
the kept data. The wire-shapes spec closes its browser context when a
setup step throws before the close callback is handed to the test.
2026-08-04 01:59:34 +02:00
Misha Bragin b66cc4ccb7 Cache pricing + custom model entry (#736) 2026-08-01 15:56:24 +02:00
Zoltan Papp 1d408f2e7e Add openSUSE zypper instructions with GPG key import to the Linux tab (#735)
Zypper ignores the gpgkey= entry of a .repo file, so the yum snippet would
leave the repository key uninstalled on openSUSE. The new distro entry adds
the repository with GPG checking enabled and imports the key on refresh.
2026-07-28 17:50:09 +02:00
Maycon Santos 9fab9d7a83 Cover RPM-based distros and desktop app dependencies in the Linux tab (#733)
* Cover RPM-based distros and desktop app dependencies in the Linux tab

The manual-install section of the add-peer modal only covered Ubuntu via
APT. Replace the single Ubuntu accordion with a distro selector covering
Debian/Ubuntu (APT), Fedora (DNF), RHEL/AlmaLinux/Rocky (DNF), and Amazon
Linux (YUM).

The desktop app links GTK 4.10+ and WebKitGTK 6.0 but the released
netbird-ui packages do not declare them, so each desktop-app install line
names the packages explicitly and a callout states the minimum release
(Ubuntu 24.04, Debian 13, Fedora 43, RHEL 10 with EPEL). Distros without
the required stack get CLI-only instructions.

* Add e2e coverage for the Linux distro selector
2026-07-28 03:03:46 +09:00
Maycon Santosandbraginini 6ca91287fc Show agent-network prompt-cache tokens and cost on hover (#731)
Co-authored-by: braginini <bangvalo@gmail.com>
2026-07-26 21:43:33 +02:00
Misha Bragin 5a5e04ad35 Add 0.75 announcement (#727) 2026-07-23 21:05:52 +02:00
Maycon Santos 598ecb196d remove rc releases logic for agent networks (#725) 2026-07-23 20:29:09 +02:00
Maycon Santosandbraginini 7c8352822d Add Kimi (Moonshot AI) provider support to Agent Network UI (#724)
* Add Kimi (Moonshot AI) provider support to Agent Network UI

Pairs with the management catalog's new kimi_api entry:

- kimi_api in the AIProviderId union and the access-log provider-id
  normalization list.
- Provider modal: api.moonshot.ai host placeholder, upstream-URL help
  explaining the OpenAI-shape (/v1) vs Anthropic-shape (/anthropic)
  path choice, sk-... key placeholder.
- Configure Your Agent: Kimi backend option in the Claude Code tab
  (JSON + shell modes), a Kimi variant in the Codex tab using
  wire_api="chat", and a new Kimi CLI tab with the ~/.kimi/config.toml
  provider setup. All Kimi surfaces render only when a kimi_api
  provider is connected, and pre-select themselves when Kimi is the
  only provider of its shape.
- Onboarding defaults to the Claude Code tab for Kimi providers.

* Add Kimi provider e2e spec

Walks the Kimi lifecycle against the real backend: select kimi_api from
the catalog (prefilled host, catalog model pricing), connect it, then
assert the Kimi-gated Configure Your Agent surfaces (Kimi CLI tab,
Claude Code kimi-k3 config, Codex wire_api=chat variant) and clean up
via new agent-network API helpers. Skips when the management build
under test has no kimi_api catalog entry, so it can merge ahead of the
backend and activate once the management-cloud image carries it.

* Remove Codex option for Kimi and align Claude Code config with Moonshot guide

Codex has no clear support against Moonshot's upstream, so the Kimi
variant is dropped from the Codex tab. The Claude Code Kimi snippet now
pins all default model tiers and the subagent model to kimi-k3 and
disables tool search, per Moonshot's Claude Code guide.

* Carry the /anthropic shape prefix in the agent base URL for Kimi

The Kimi provider keeps its default https://api.moonshot.ai upstream;
Claude Code and Kimi CLI select the Anthropic shape by appending
/anthropic to the endpoint base URL, which rides through to Moonshot.
One provider now serves both API shapes.

* Kimi CLI uses the bare endpoint base URL

Only Claude Code carries the /anthropic prefix; Kimi CLI's anthropic
provider type points at the bare endpoint.

* Kimi tooltip

* Make the Claude Code provider dropdown width flexible and tidy Kimi notes

* Make the Claude Code provider dropdown width flexible and tidy Kimi notes

* Restored the note on the Kimi CLI tab

---------

Co-authored-by: braginini <bangvalo@gmail.com>
2026-07-23 19:24:41 +02:00
Maycon Santos e4f5046ff2 [agent-network] per-provider disable-metadata toggle (#715) 2026-07-20 14:46:17 +02:00
Eduard GertandClaude Opus 4.8 d5f61efc1a Fix network resource policy creation with single peer source (#721)
When creating a network resource and adding a policy with a single peer
(sourceResource) as the source, the payload included both an empty
`sources: []` array and `sourceResource`, causing the backend to reject
it with "specify either sources or source resources not both".

`createPolicyForResource` always computed `sources` as an array while the
`...rule` spread carried `sourceResource` through. This mirrors the
existing destinations handling: gate `sources` on `rule.sourceResource`
so it becomes undefined (and is dropped during serialization) when a
source resource is set.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 11:16:36 +02:00
Maycon Santos 3503141c56 Adjust license cache ttl to 10 minutes (#718) 2026-07-20 11:03:14 +02:00
Misha Bragin a27e4654cf Fix IdP cards spacing (#717) 2026-07-16 14:41:59 +02:00
Misha Bragin b9569f48d5 Add clusters view to agent network (#714) 2026-07-15 23:22:05 +02:00
Viktor Liu 9694bb753d Show RDP for Linux and FreeBSD peers and harden the browser RDP client (#710) 2026-07-15 12:29:54 +02:00
38 changed files with 2432 additions and 588 deletions

No files matched your search

+3 -3
View File
@@ -39,7 +39,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
tag: v0.0.2
fileName: "*.ts"
out-file-path: 'public/ironrdp-pkg'
@@ -48,7 +48,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
tag: v0.0.2
fileName: "*.js"
out-file-path: 'public/ironrdp-pkg'
@@ -57,7 +57,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
tag: v0.0.2
fileName: "ironrdp_web_bg.wasm"
out-file-path: 'public/ironrdp-pkg'
+3 -3
View File
@@ -1,8 +1,8 @@
[
{
"tag": "Preview",
"text": "The new NetBird desktop app is here - now available as a 0.75 release candidate.",
"link": "https://github.com/netbirdio/netbird/discussions/6483",
"tag": "New",
"text": "The new NetBird desktop app is here - now available in the v0.75 release.",
"link": "https://netbird.io/knowledge-hub/netbird-v0-75-new-desktop-app",
"linkText": "Learn more",
"variant": "important",
"isExternal": true,
+50
View File
@@ -433,3 +433,53 @@ export async function deleteUserByEmail(page: Page, email: string) {
await deleteUserById(page, user.id);
}
}
// ---------------------------------------------------------------------------
// Agent Network
// ---------------------------------------------------------------------------
type AgentNetworkCatalogProvider = {
id: string;
name: string;
};
type AgentNetworkProvider = {
id: string;
name: string;
provider_id: string;
};
/** List the Agent Network provider catalog (server-defined provider types). */
export async function listAgentNetworkCatalog(
page: Page,
): Promise<AgentNetworkCatalogProvider[]> {
return apiGet<AgentNetworkCatalogProvider[]>(
page,
"/agent-network/catalog/providers",
);
}
/** List connected Agent Network providers. */
export async function listAgentNetworkProviders(
page: Page,
): Promise<AgentNetworkProvider[]> {
return apiGet<AgentNetworkProvider[]>(page, "/agent-network/providers");
}
/** Delete an Agent Network provider by ID. */
export async function deleteAgentNetworkProviderById(page: Page, id: string) {
await apiDelete(page, `/agent-network/providers/${id}`);
}
/** Delete all Agent Network providers whose name starts with the prefix. */
export async function deleteAgentNetworkProvidersByPrefix(
page: Page,
prefix: string,
) {
const providers = await listAgentNetworkProviders(page);
for (const p of providers) {
if (p.name.startsWith(prefix)) {
await deleteAgentNetworkProviderById(page, p.id);
}
}
}
@@ -0,0 +1,213 @@
/**
* Agent Network prompt-cache accounting spec.
*
* Drives the Usage & Logs page against mocked agent-network endpoints whose
* rows carry the cache fields (cached_input_tokens / cache_creation_tokens /
* cache_cost_usd) plus the per-bucket cost breakdown, and verifies the hover
* breakdowns: the access-log Tokens tooltip gains cache read/write rows and a
* cache-aware total, the Cost tooltip lists one line per billed bucket, and
* the usage overview's daily table
* surfaces the day's cache buckets. Route mocks keep the spec hermetic, so it
* passes against management builds that predate the cache fields.
*/
import { test, expect, type Browser, type Page } from "@playwright/test";
import { loginToApp, navigateTo } from "../helpers/auth";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
// The reported field scenario: a session-start request that wrote a ~32k-token
// prompt cache, and a follow-up that read it back.
const writeEntry = {
id: "anlog-cache-write",
service_id: "svc-e2e",
timestamp: new Date().toISOString(),
status_code: 200,
duration_ms: 29000,
input_tokens: 10,
output_tokens: 1472,
total_tokens: 33591,
cached_input_tokens: 0,
cache_creation_tokens: 32109,
cost_usd: 0.142519,
cache_cost_usd: 0.120409,
input_cost_usd: 0.00003,
cached_input_cost_usd: 0,
cache_creation_cost_usd: 0.120409,
output_cost_usd: 0.02208,
provider: "bedrock",
model: "anthropic.claude-sonnet-4-5",
session_id: "sess-cache-write",
decision: "allow",
stream: false,
};
const readEntry = {
...writeEntry,
id: "anlog-cache-read",
output_tokens: 800,
total_tokens: 32919,
cached_input_tokens: 32109,
cache_creation_tokens: 0,
cost_usd: 0.021663,
cache_cost_usd: 0.009633,
input_cost_usd: 0.00003,
cached_input_cost_usd: 0.009633,
cache_creation_cost_usd: 0,
output_cost_usd: 0.012,
session_id: "sess-cache-read",
};
const todayBucket = {
period_start: new Date().toISOString().slice(0, 10),
input_tokens: 20,
output_tokens: 2272,
total_tokens: 66510,
cached_input_tokens: 32109,
cache_creation_tokens: 32109,
cost_usd: 0.164182,
cache_cost_usd: 0.130042,
input_cost_usd: 0.00006,
cached_input_cost_usd: 0.009633,
cache_creation_cost_usd: 0.120409,
output_cost_usd: 0.03408,
};
async function mockCacheUsage(page: Page) {
await page.route("**/api/agent-network/access-logs*", (route) =>
route.fulfill({
json: {
data: [writeEntry, readEntry],
page: 1,
page_size: 25,
total_records: 2,
total_pages: 1,
},
}),
);
await page.route("**/api/agent-network/usage/overview*", (route) =>
route.fulfill({ json: [todayBucket] }),
);
}
async function newUsagePage(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 mockCacheUsage(page);
await loginToApp(page, "owner");
return { page, close: () => context.close() };
}
// FullTooltip force-mounts its content, so a tooltip keeps its role=tooltip
// element in the DOM after closing — a bare getByRole("tooltip") accumulates
// every tooltip the test has ever opened. Radix puts role=tooltip on a
// visually-hidden span and the open/closed flag on that span's parent
// ("delayed-open"/"instant-open" vs "closed"), so match the parent that is
// currently open. Its text covers both the visible copy and the hidden one.
function tooltip(page: Page) {
return page.locator(
'[data-state="delayed-open"]:has(> [role="tooltip"]), [data-state="instant-open"]:has(> [role="tooltip"])',
);
}
// Radix's hoverable content holds a tooltip open while the pointer travels
// toward it, so moving straight to the next cell can leave two open at once.
// Escape dismisses the open one deterministically, wherever the pointer sits.
async function closeTooltip(page: Page) {
await page.keyboard.press("Escape");
await expect(tooltip(page)).toHaveCount(0);
}
test.describe.serial("Agent Network cache accounting @agent-network", () => {
test("access-log hover breaks out prompt-cache tokens and cost", async ({
browser,
}) => {
const { page, close } = await newUsagePage(browser);
try {
await navigateTo(page, "/agent-network/usage?tab=access-logs");
const writeRow = page.getByRole("row").filter({ hasText: "$0.1425" });
await expect(writeRow).toBeVisible();
// Tokens tooltip: cache-write bucket plus a total that includes it.
await writeRow.getByText("1,472").hover();
await expect(tooltip(page)).toContainText("32,109");
await expect(tooltip(page)).toContainText("cache write");
await expect(tooltip(page)).toContainText("33,591");
// Cost tooltip: all four buckets are listed plus the total, including
// buckets that cost nothing — this request wrote the cache but never read
// it, so "cache read" is present with a $0.0000 amount.
await closeTooltip(page);
await writeRow.getByText("$0.1425").hover();
await expect(tooltip(page)).toContainText("input");
await expect(tooltip(page)).toContainText("cache read");
await expect(tooltip(page)).toContainText("cache write");
await expect(tooltip(page)).toContainText("output");
await expect(tooltip(page)).toContainText("$0.1204");
await expect(tooltip(page)).toContainText("$0.0221");
await expect(tooltip(page)).toContainText("$0.0000");
await expect(tooltip(page)).toContainText("total");
// The follow-up request reads the cache back: read bucket filled, write
// bucket still listed as a zero row.
// No exact match: the output count renders behind an sr-only "Output:" prefix.
const readRow = page.getByRole("row").filter({ hasText: "$0.0217" });
await closeTooltip(page);
await readRow.getByText("800").hover();
await expect(tooltip(page)).toContainText("cache read");
await expect(tooltip(page)).toContainText("32,919");
await expect(tooltip(page)).toContainText(/0\s*cache write/);
} finally {
await close();
}
});
test("usage overview daily table surfaces the day's cache buckets", async ({
browser,
}) => {
const { page, close } = await newUsagePage(browser);
try {
await navigateTo(page, "/agent-network/usage");
// Row-scoped on purpose: the hovers now repeat the cell's own figure on
// their total row, and FullTooltip force-mounts that content, so a
// page-wide getByText would match the cell and the tooltip both. Radix
// portals tooltip content to <body>, outside the row.
const dayRow = page.getByRole("row").filter({ hasText: "$0.16" });
// Total Tokens includes the additive cache buckets; hover splits them out
// in the same value-then-label rows the access-log table uses.
await expect(dayRow.getByText("66,510")).toBeVisible();
await dayRow.getByText("66,510").hover();
await expect(tooltip(page)).toContainText(/20\s*input/);
await expect(tooltip(page)).toContainText(/2,272\s*output/);
await expect(tooltip(page)).toContainText(/32,109\s*cache read/);
await expect(tooltip(page)).toContainText(/32,109\s*cache write/);
await expect(tooltip(page)).toContainText(/66,510\s*total/);
// Cost hover breaks the day into the same four billed buckets.
await closeTooltip(page);
await dayRow.getByText("$0.16", { exact: true }).hover();
await expect(tooltip(page)).toContainText(/\$0\.0001\s*input/);
await expect(tooltip(page)).toContainText(/\$0\.0341\s*output/);
await expect(tooltip(page)).toContainText(/\$0\.0096\s*cache read/);
await expect(tooltip(page)).toContainText(/\$0\.1204\s*cache write/);
await expect(tooltip(page)).toContainText(/\$0\.1642\s*total/);
} finally {
await close();
}
});
});
@@ -0,0 +1,165 @@
/**
* Agent Network Kimi (Moonshot AI) provider spec.
*
* 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.
*
* The kimi_api catalog entry ships with newer management builds. When the
* backend under test predates it, the whole suite skips instead of failing —
* same trade-off as the provider matrix in netbird's agent-network e2e
* harness, which skips per-provider on missing credentials.
*
* The Agent Network menu is deployment-gated; the test build honors the
* 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 { loginToApp } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import {
deleteAgentNetworkProvidersByPrefix,
listAgentNetworkCatalog,
} 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-";
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() };
}
test.describe.serial("Agent Network Kimi provider @agent-network", () => {
test("connect a Kimi provider and see Kimi agent configs", async ({
browser,
}) => {
const { page, close } = await newAgentNetworkPage(browser);
try {
// Backend support probe: skip the suite on management builds whose
// catalog predates kimi_api.
const catalog = await listAgentNetworkCatalog(page);
test.skip(
!catalog.some((c) => c.id === KIMI_CATALOG_ID),
`management catalog has no ${KIMI_CATALOG_ID} entry`,
);
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
await page.goto("/agent-network/providers");
await page.keyboard.press("Escape");
// ---- connect the provider ----
await page
.getByRole("button", { name: "Connect Provider" })
.first()
.click({ force: true });
// The provider select defaults to OpenAI API; search the catalog for
// the Kimi entry.
await page
.getByRole("button", { name: /OpenAI API/ })
.first()
.click({ force: true });
await page.getByPlaceholder("Search providers...").fill("kimi");
await page.getByText(KIMI_CATALOG_NAME).first().click({ force: true });
// Catalog default host lands in the upstream URL input.
await expect(
page.locator('input[value="https://api.moonshot.ai"]'),
).toBeVisible();
await page
.getByPlaceholder("sk-...")
.first()
.fill("sk-e2e-kimi-test-key");
const providerName = generateRandomName(PROVIDER_PREFIX);
const nameInput = page.locator(`input[value="${KIMI_CATALOG_NAME}"]`);
await nameInput.fill(providerName);
// ---- models tab: catalog models carry pricing ----
await page.getByRole("tab", { name: "Models" }).click({ force: true });
await page.getByRole("button", { name: "Add More" }).click({ force: true });
await page.getByText("Kimi K3", { exact: false }).first().click({ force: true });
// Catalog price for kimi-k3 input tokens pre-fills the price cell.
await expect(page.locator('input[value="0.003"]')).toBeVisible();
const createResponse = page.waitForResponse(
(resp) =>
resp.url().includes("/agent-network/providers") &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await page
.getByRole("button", { name: "Connect Provider" })
.last()
.click({ force: true });
expect([200, 201]).toContain((await createResponse).status());
// 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 });
// Kimi CLI tab only renders when a kimi_api provider is connected.
await expect(page.getByRole("tab", { name: "Kimi CLI" })).toBeVisible();
// Claude Code tab's backend dropdown offers (and, with Kimi as the only
// Anthropic-shaped provider, pre-selects) Kimi — its settings.json
// snippet pins every model slot to kimi-k3 and disables tool search,
// per Moonshot's Claude Code guide.
await expect(page.getByText('"ANTHROPIC_MODEL": "kimi-k3"')).toBeVisible();
await expect(
page.getByText('"CLAUDE_CODE_SUBAGENT_MODEL": "kimi-k3"'),
).toBeVisible();
await expect(
page.getByText('"ENABLE_TOOL_SEARCH": "false"'),
).toBeVisible();
// The base URL carries the /anthropic shape prefix that rides through
// the endpoint to Moonshot's Anthropic surface.
await expect(
page.getByText(/"ANTHROPIC_BASE_URL": "https:\/\/[^"]+\/anthropic"/),
).toBeVisible();
// Kimi CLI tab carries the ~/.kimi/config.toml provider block.
await page.getByRole("tab", { name: "Kimi CLI" }).click({ force: true });
await expect(page.getByText('default_model = "kimi-k3"')).toBeVisible();
// Codex has no Kimi variant — Kimi's upstream doesn't support Codex, so
// the tab keeps the plain Responses-API config with no backend dropdown.
await page.getByRole("tab", { name: "Codex" }).click({ force: true });
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);
} finally {
await close();
}
});
});
@@ -0,0 +1,144 @@
/**
* Agent Network settings wire-shape spec.
*
* The management API signals "account not bootstrapped" differently by
* generation: current servers answer GET /agent-network/settings with the
* defaults object carrying an empty cluster/subdomain/endpoint, older ones
* with 200 + a JSON null body, and the oldest with a 404.
* useAgentNetworkSettings normalizes all three to the same null-settings
* signal, and this spec pins that: the providers page must render the
* connect-first endpoint placeholder for every unbootstrapped shape, and the
* endpoint badge once the account is bootstrapped. The settings route is
* mocked per test, so the assertions do not depend on the backend build or
* on account state left behind by other suites.
*/
import { test, expect, type Browser, type Page } from "@playwright/test";
import { loginToApp } from "../helpers/auth";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
const EMPTY_STATE_TEXT =
"Connect your first provider to set up your agent network endpoint.";
const BOOTSTRAPPED_ENDPOINT = "violet.eu.proxy.netbird.io";
// The defaults object current servers return before the account is
// bootstrapped: values present, cluster/subdomain/endpoint empty, no
// timestamps (no row has been persisted yet).
const UNBOOTSTRAPPED_DEFAULTS = {
cluster: "",
subdomain: "",
endpoint: "",
enable_log_collection: true,
enable_prompt_collection: false,
redact_pii: false,
access_log_retention_days: 30,
};
const BOOTSTRAPPED_SETTINGS = {
cluster: "eu.proxy.netbird.io",
subdomain: "violet",
endpoint: BOOTSTRAPPED_ENDPOINT,
enable_log_collection: true,
enable_prompt_collection: false,
redact_pii: false,
access_log_retention_days: 30,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
async function mockSettingsResponse(
page: Page,
response: { status: number; body: string },
) {
await page.route("**/api/agent-network/settings", (route) => {
if (route.request().method() !== "GET") return route.continue();
return route.fulfill({
status: response.status,
contentType: "application/json",
body: response.body,
});
});
}
async function openProvidersPage(
browser: Browser,
settingsResponse: { status: number; body: string },
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
// Close the context on any setup failure — the caller only receives the
// close callback once setup succeeds.
try {
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 mockSettingsResponse(page, settingsResponse);
await loginToApp(page, "owner");
await page.goto("/agent-network/providers");
await page.keyboard.press("Escape");
return { page, close: () => context.close() };
} catch (e) {
await context.close();
throw e;
}
}
const UNBOOTSTRAPPED_SHAPES: {
name: string;
response: { status: number; body: string };
}[] = [
{
name: "defaults object with empty endpoint (current servers)",
response: { status: 200, body: JSON.stringify(UNBOOTSTRAPPED_DEFAULTS) },
},
{
name: "200 with JSON null body (older servers)",
response: { status: 200, body: "null" },
},
{
name: "404 (oldest servers)",
response: {
status: 404,
body: JSON.stringify({ message: "settings not found", code: 404 }),
},
},
];
test.describe("Agent Network settings wire shapes @agent-network", () => {
for (const shape of UNBOOTSTRAPPED_SHAPES) {
test(`unbootstrapped account renders the empty state: ${shape.name}`, async ({
browser,
}) => {
const { page, close } = await openProvidersPage(browser, shape.response);
try {
await expect(page.getByText(EMPTY_STATE_TEXT)).toBeVisible();
await expect(page.getByText(BOOTSTRAPPED_ENDPOINT)).toHaveCount(0);
} finally {
await close();
}
});
}
test("bootstrapped account renders the endpoint instead of the empty state", async ({
browser,
}) => {
const { page, close } = await openProvidersPage(browser, {
status: 200,
body: JSON.stringify(BOOTSTRAPPED_SETTINGS),
});
try {
await expect(page.getByText(BOOTSTRAPPED_ENDPOINT)).toBeVisible();
await expect(page.getByText(EMPTY_STATE_TEXT)).toHaveCount(0);
} finally {
await close();
}
});
});
+73 -4
View File
@@ -72,13 +72,19 @@ function mockAccounts(page: Page, state: AccountState, delayMs: number) {
async function openOnboarding(
browser: Browser,
opts: { source?: boolean; account: AccountState; delayMs?: number },
opts: {
source?: boolean;
account: AccountState;
delayMs?: number;
edition?: string;
expectOnboarding?: boolean;
},
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
await context.addInitScript(
([sourceKey, sourceValue, withSource]) => {
([sourceKey, sourceValue, withSource, edition]) => {
try {
window.localStorage.setItem("netbird-test-onboarding", "true");
if (withSource) {
@@ -87,13 +93,32 @@ async function openOnboarding(
sourceValue as string,
);
}
if (edition) {
window.localStorage.setItem("netbird-test-edition", edition as string);
}
} catch (e) {}
},
[SIGNUP_SOURCE_KEY, AGENT_NETWORK_SOURCE, !!opts.source] as const,
[
SIGNUP_SOURCE_KEY,
AGENT_NETWORK_SOURCE,
!!opts.source,
opts.edition ?? "",
] as const,
);
const page = await context.newPage();
mockAccounts(page, opts.account, opts.delayMs ?? 0);
await loginToApp(page, "owner", { expectOnboarding: true });
// The mocked GET /accounts is what drives the onboarding decision, and the
// dashboard can resolve before it lands — a "no form opened" assertion would
// then pass vacuously. Wait for the rewritten response before returning.
const accountsLoaded = page.waitForResponse(
(resp) =>
/\/api\/accounts(\?|$)/.test(resp.url()) &&
resp.request().method() === "GET",
);
await loginToApp(page, "owner", {
expectOnboarding: opts.expectOnboarding ?? true,
});
await accountsLoaded;
return { page, close: () => context.close() };
}
@@ -143,6 +168,50 @@ test.describe.serial("Onboarding form selection @onboarding", () => {
}
});
test("a self-hosted account with onboarding pending shows the regular flow at the intent step", async ({
browser,
}) => {
const { page, close } = await openOnboarding(browser, {
source: false,
edition: "oss",
account: { onboardingFlowPending: true },
});
try {
await expect(page.getByTestId(REGULAR_FORM)).toBeVisible();
await expect(page.getByTestId(AGENT_FORM)).toHaveCount(0);
// The signup survey relies on a JWT domain claim self-hosted IdPs don't
// emit, so the flow skips it and opens on the intent step. Scoped to the
// form: the dashboard behind the modal has its own "Get Started with
// NetBird" heading, and getByText matches case-insensitively.
await expect(
page
.getByTestId(REGULAR_FORM)
.getByRole("heading", { name: "Get started with NetBird" }),
).toBeVisible();
} finally {
await close();
}
});
test("a self-hosted account with only the signup form pending shows no onboarding", async ({
browser,
}) => {
const { page, close } = await openOnboarding(browser, {
source: false,
edition: "oss",
account: { signupFormPending: true },
expectOnboarding: false,
});
try {
// loginToApp resolved the dashboard, so the account state has been
// applied — neither flow should have opened.
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
await expect(page.getByTestId(AGENT_FORM)).toHaveCount(0);
} finally {
await close();
}
});
test("a slow backend never flashes the regular form for a netbird.ai signup", async ({
browser,
}) => {
+45 -12
View File
@@ -1,6 +1,6 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import { clearScrollLock, generateRandomName } from "../helpers/utils";
import { deleteGroupsByPrefix, deleteSetupKeysByPrefix } from "../helpers/api";
let setupKeys: string[] = [];
@@ -118,19 +118,42 @@ async function createSetupKey(
await expect(page.getByText(opts.name)).toBeVisible();
}
async function revokeSetupKey(
// openRowActions opens a row's action dropdown. A confirmation dialog closed
// moments earlier can still leave Radix's scroll-lock artifacts on <body>
// (`pointer-events: none`, a stale overlay), and those swallow the click even
// with force — the menu then silently never opens and the item lookup times
// out. Clear them before clicking.
async function openRowActions(
page: import("@playwright/test").Page,
name: string,
) {
// Row actions are now behind a dropdown menu.
await clearScrollLock(page);
// A force-click can open a new action menu without the previous one having
// closed (scroll-lock artifacts suppress Radix's outside-click dismissal),
// leaving two menus open — the item lookup then hits a strict-mode violation.
// Dismiss any open menu and wait for it to be gone before opening the next.
if (await page.locator('[role="menu"]').count()) {
await page.keyboard.press("Escape");
await expect(page.locator('[role="menu"]')).toHaveCount(0);
}
await page
.locator("tr")
.filter({ hasText: name })
.getByTestId("setup-key-actions")
.click({ force: true });
await page
.locator('[data-testid="revoke-setup-key"]:not([data-disabled])')
.click({ force: true });
}
async function revokeSetupKey(
page: import("@playwright/test").Page,
name: string,
) {
// Row actions are now behind a dropdown menu.
await openRowActions(page, name);
const revokeItem = page.locator(
'[data-testid="revoke-setup-key"]:not([data-disabled])',
);
await expect(revokeItem).toBeVisible();
await revokeItem.click({ force: true });
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes("/api/setup-keys/") && resp.request().method() === "PUT",
{ timeout: 10_000 },
@@ -151,13 +174,23 @@ async function deleteSetupKey(
name: string,
) {
// Row actions are now behind a dropdown menu.
await page
.locator("tr")
.filter({ hasText: name })
.getByTestId("setup-key-actions")
.click({ force: true });
await page.getByTestId("delete-setup-key").click({ force: true });
await openRowActions(page, name);
const deleteItem = page.locator(
'[data-testid="delete-setup-key"]:not([data-disabled])',
);
await expect(deleteItem).toBeVisible();
await deleteItem.click({ force: true });
// Await the DELETE like revoke awaits its PUT: the handler refetches
// /setup-keys afterwards, which re-renders every row — returning before that
// settles means the next key's trigger click can land on a replaced node.
const responsePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/api/setup-keys/") &&
resp.request().method() === "DELETE",
{ timeout: 10_000 },
);
await page.getByTestId("confirmation.confirm").click();
await responsePromise;
await expect(page.locator("tr").filter({ hasText: name })).not.toBeVisible();
await expect(page.getByRole("dialog")).not.toBeVisible();
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Linux tab of the setup modal — distro selector spec.
*
* The manual-install accordion offers per-distro instructions via a
* SelectDropdown (data-testid="linux-distro-select"). This validates that
* switching distros swaps the repository and install commands, that
* CLI-only distros render no desktop-app line, and that the copy button
* emits commands without comment lines. Runs against /install, which
* renders the setup modal without authentication.
*/
import { expect, test } from "@playwright/test";
async function openLinuxManualInstall(page: import("@playwright/test").Page) {
await page.goto("/install");
await page.getByTestId("setup-netbird-modal").waitFor();
await page.getByRole("tab", { name: /linux/i }).click();
await page.getByText("Install manually with a package manager").click();
await page.getByTestId("linux-distro-select").waitFor();
}
async function selectDistro(
page: import("@playwright/test").Page,
name: string,
) {
await page.getByTestId("linux-distro-select").click();
await page.getByRole("option", { name }).click();
}
test.describe("Setup modal Linux distro instructions", () => {
test("defaults to APT with repository, install and run steps", async ({
page,
}) => {
await openLinuxManualInstall(page);
const modal = page.getByTestId("setup-netbird-modal");
await expect(modal.getByTestId("linux-distro-select")).toContainText(
"Debian / Ubuntu (APT)",
);
await expect(modal).toContainText(
"https://pkgs.netbird.io/debian stable main",
);
await expect(modal).toContainText("sudo apt-get install netbird");
await expect(modal).toContainText(
"sudo apt-get install netbird-ui libgtk-4-1 libwebkitgtk-6.0-4 xdg-utils",
);
// The run step stays present alongside the distro-specific steps
await expect(modal.getByText("netbird up")).toHaveCount(2);
});
test("switches repository and install commands per distro", async ({
page,
}) => {
await openLinuxManualInstall(page);
const modal = page.getByTestId("setup-netbird-modal");
await selectDistro(page, "Fedora (DNF)");
await expect(modal).toContainText("/etc/yum.repos.d/netbird.repo");
await expect(modal).toContainText("sudo dnf install netbird");
await expect(modal).toContainText(
"sudo dnf install netbird-ui gtk4 webkitgtk6.0 xdg-utils",
);
await expect(modal).not.toContainText("apt-get");
await expect(modal).not.toContainText("epel-release");
await selectDistro(page, "RHEL / AlmaLinux / Rocky (DNF)");
await expect(modal).toContainText("sudo dnf install epel-release -y");
await expect(modal).toContainText(
"sudo dnf install netbird-ui gtk4 webkitgtk6.0 xdg-utils",
);
});
test("imports the repository GPG key on openSUSE", async ({ page }) => {
await openLinuxManualInstall(page);
const modal = page.getByTestId("setup-netbird-modal");
await selectDistro(page, "openSUSE (Zypper)");
await expect(modal).toContainText(
"sudo zypper --non-interactive addrepo -f -g https://pkgs.netbird.io/yum/ netbird",
);
await expect(modal).toContainText(
"sudo zypper --gpg-auto-import-keys refresh netbird",
);
await expect(modal).toContainText("sudo zypper install netbird");
await expect(modal).toContainText(
"sudo zypper install netbird-ui libgtk-4-1 libwebkitgtk-6_0-4 xdg-utils",
);
// Zypper ignores gpgkey= in a .repo file, so that snippet must not appear
await expect(modal).not.toContainText("/etc/yum.repos.d/netbird.repo");
});
test("offers CLI only on Amazon Linux", async ({ page }) => {
await openLinuxManualInstall(page);
const modal = page.getByTestId("setup-netbird-modal");
await selectDistro(page, "Amazon Linux (YUM)");
await expect(modal).toContainText("sudo yum install netbird");
await expect(modal).not.toContainText("netbird-ui");
await expect(modal).not.toContainText("for CLI only");
await expect(modal).toContainText("only the CLI is available");
});
test("copies install commands without comment lines", async ({
page,
context,
}) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await openLinuxManualInstall(page);
const modal = page.getByTestId("setup-netbird-modal");
// Copy buttons in DOM order: install script, run command, repository,
// install step, run command (accordion).
await modal.getByTestId("copy-to-clipboard").nth(3).click();
const copied = await page.evaluate(() => navigator.clipboard.readText());
expect(copied, "clipboard should hold runnable commands only").toBe(
[
"sudo apt-get update",
"sudo apt-get install netbird",
"sudo apt-get install netbird-ui libgtk-4-1 libwebkitgtk-6.0-4 xdg-utils",
].join("\n"),
);
});
});
+8
View File
@@ -22,7 +22,15 @@ test.describe.serial("Team - Service Users @team", () => {
test("Should update role and manage access tokens", async ({ dashboardAsOwner: page }) => {
await page.locator("tr").getByText(regularUser).click();
await changeRoleTo(page, "Admin");
// Await the PUT so the role change is persisted before the next serial
// test asserts it — clicking save alone returns before the request lands.
const saveResponse = page.waitForResponse(
(resp) =>
resp.url().includes("/api/users/") && resp.request().method() === "PUT",
{ timeout: 30_000 },
);
await page.getByTestId("save-changes").click();
await saveResponse;
// Create and delete access token
const tokenName = generateRandomName("tkn_");
@@ -1,25 +1,33 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { VerticalTabs } from "@components/VerticalTabs";
import * as Tabs from "@radix-ui/react-tabs";
import { Gauge, ScrollText } from "lucide-react";
import { ExternalLinkIcon, Gauge, ScrollText, ServerIcon } from "lucide-react";
import { useSearchParams } from "next/navigation";
import React, { Suspense, useEffect, useState } from "react";
import React, { lazy, Suspense, useEffect, useState } from "react";
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
import GroupsProvider from "@/contexts/GroupsProvider";
import PeersProvider from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { REVERSE_PROXY_CLUSTERS_DOCS_LINK } from "@/interfaces/ReverseProxy";
import PageContainer from "@/layouts/PageContainer";
import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode";
import AgentAccountControlsCard from "@/modules/agent-network/AgentAccountControlsCard";
import AgentBudgetRulesTable from "@/modules/agent-network/AgentBudgetRulesTable";
import AIProvidersProvider from "@/modules/agent-network/AIProvidersProvider";
const ClustersTable = lazy(
() => import("@/modules/reverse-proxy/clusters/ClustersTable"),
);
const TAB_BUDGET_SETTINGS = "budget-settings";
const TAB_LOG_SETTINGS = "log-settings";
const TAB_CLUSTERS = "clusters";
// AgentNetworkConfigurationPage holds the configuration surfaces extracted from
// Usage & Logs — global limits and log-collection controls — using the same
@@ -27,6 +35,7 @@ const TAB_LOG_SETTINGS = "log-settings";
// Settings page so it reads as "settings for the Agent Network".
export default function AgentNetworkConfigurationPage() {
const { permission } = usePermissions();
const { only: agentNetworkOnly } = useAgentNetworkMode();
const queryParams = useSearchParams();
const queryTab = queryParams.get("tab");
const [tab, setTab] = useState(queryTab ?? TAB_BUDGET_SETTINGS);
@@ -47,6 +56,10 @@ export default function AgentNetworkConfigurationPage() {
<ScrollText size={14} />
Log Collection
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value={TAB_CLUSTERS}>
<ServerIcon size={14} />
Clusters
</VerticalTabs.Trigger>
</VerticalTabs.List>
<RestrictedAccess
page={"Configuration"}
@@ -82,6 +95,27 @@ export default function AgentNetworkConfigurationPage() {
<AgentAccountControlsCard />
</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"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</ConfigTabHeader>
<Suspense fallback={<SkeletonTable />}>
<ClustersTable />
</Suspense>
</Tabs.Content>
</div>
</AIProvidersProvider>
</PeersProvider>
+16 -1
View File
@@ -7,6 +7,8 @@ import useFetchApi from "@utils/api";
import { Loader2Icon } from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { Peer } from "@/interfaces/Peer";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { RDPCertificateModal } from "@/modules/remote-access/rdp/RDPCertificateModal";
import { RDPCredentialsModal } from "@/modules/remote-access/rdp/RDPCredentialsModal";
import { useRDPQueryParams } from "@/modules/remote-access/rdp/useRDPQueryParams";
@@ -138,6 +140,8 @@ function RDPSession({ peer }: Props) {
domain: credentials.domain,
width: window.innerWidth,
height: window.innerHeight,
dynamicResize:
getOperatingSystem(peer?.os) === OperatingSystem.WINDOWS,
});
if (result === RDPStatus.CONNECTED) {
connected.current = true;
@@ -182,7 +186,18 @@ function RDPSession({ peer }: Props) {
if (client.error) {
sendErrorNotification("NetBird Client Error", client.error);
}
}, [rdp, client]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rdp.error, client.error]);
/**
* Return to the credentials screen when an established session drops with an
* error, so the user can see what happened and retry.
*/
useEffect(() => {
if (connected.current && rdp.error) {
reset();
}
}, [rdp.error, reset]);
/**
* Close credentials modal when RDP is connected
Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

+24 -8
View File
@@ -17,6 +17,13 @@ function formatVersion(version: string): string {
return version;
}
// Self-hosted builds can carry a numeric build suffix (e.g. "0.76.3-31256681241")
// that overflows the sidebar. Show the semver only and keep the rest for the
// tooltip. Pre-release labels like "-rc.1" are left intact so they stay visible.
function formatShortVersion(version: string): string {
return formatVersion(version).replace(/^(v?\d+(?:\.\d+)*)-\d+$/, "$1");
}
function compareVersions(current: string, latest: string): boolean {
// Returns true if latest is newer than current
if (!current || !latest) return false;
@@ -98,9 +105,15 @@ const NavigationVersionInfoContent = () => {
<div className="flex flex-col gap-1 text-nb-gray-400">
<FullTooltip
content={
<span className="text-xs">
Latest: {formatVersion(versionInfo.management_available_version)}
</span>
<div className="text-xs flex flex-col gap-1">
<span>
Installed:{" "}
{formatVersion(versionInfo.management_current_version)}
</span>
<span>
Latest: {formatVersion(versionInfo.management_available_version)}
</span>
</div>
}
side="top"
className="w-full"
@@ -108,15 +121,18 @@ const NavigationVersionInfoContent = () => {
<div className="flex items-center justify-between w-full cursor-default">
<span>Management</span>
<span className="text-nb-gray-300 font-medium">
{formatVersion(versionInfo.management_current_version)}
{formatShortVersion(versionInfo.management_current_version)}
</span>
</div>
</FullTooltip>
<FullTooltip
content={
<span className="text-xs">
Latest: {formatVersion(versionInfo.dashboard_available_version)}
</span>
<div className="text-xs flex flex-col gap-1">
<span>Installed: {formatVersion(dashboardVersion)}</span>
<span>
Latest: {formatVersion(versionInfo.dashboard_available_version)}
</span>
</div>
}
side="top"
className="w-full"
@@ -124,7 +140,7 @@ const NavigationVersionInfoContent = () => {
<div className="flex items-center justify-between w-full cursor-default">
<span>Dashboard</span>
<span className="text-nb-gray-300 font-medium">
{formatVersion(dashboardVersion)}
{formatShortVersion(dashboardVersion)}
</span>
</div>
</FullTooltip>
+2 -2
View File
@@ -57,9 +57,9 @@ export default function DataTableHeader({
{children}
{sorting &&
(column.getIsSorted() === "desc" ? (
<IconSortAscending size={16} />
) : (
<IconSortDescending size={16} />
) : (
<IconSortAscending size={16} />
))}
</div>
</FullTooltip>
+5 -3
View File
@@ -64,9 +64,11 @@ export default function PoliciesProvider({ children }: Props) {
return created.id!;
};
const sources = await Promise.all(
(rule.sources ?? []).map(resolveGroup),
).then((ids) => ids.filter(Boolean) as string[]);
const sources = rule.sourceResource
? undefined
: await Promise.all(
(rule.sources ?? []).map(resolveGroup),
).then((ids) => ids.filter(Boolean) as string[]);
const destinations = rule.destinationResource
? undefined
+1 -1
View File
@@ -21,7 +21,7 @@ const ENDPOINT_EXISTS_ERROR_CODES = [401, 403, 405];
* activation be picked up without forcing the user to clear storage.
*/
const LICENSE_CACHE_PREFIX = "netbird-licensed:";
const LICENSE_CACHE_TTL_MS = 60 * 60 * 1000;
const LICENSE_CACHE_TTL_MS = 60 * 10 * 1000;
const licenseCacheKey = () => `${LICENSE_CACHE_PREFIX}${loadConfig().apiOrigin}`;
File diff suppressed because it is too large. Load diff
@@ -27,6 +27,13 @@ export type APIProviderModel = {
id: string;
input_per_1k: number;
output_per_1k: number;
// Optional cache rates. Omitted on the wire = inherit NetBird's
// default rate for this model (the backend folds it in at synthesis
// time); explicit 0 = no discount (bucket bills at the input rate).
// Never coerce undefined to 0 — the two mean different things.
cached_input_per_1k?: number;
cache_read_per_1k?: number;
cache_creation_per_1k?: number;
};
export type APIProvider = {
@@ -50,6 +57,9 @@ export type APIProvider = {
// Skip TLS certificate verification on upstream requests (custom providers
// with self-signed certs). Off by default.
skip_tls_verification?: boolean;
// Disable identity metadata injection (caller user + authorizing group) on
// upstream requests. Off by default (metadata is sent).
metadata_disabled?: boolean;
enabled: boolean;
created_at: string;
updated_at: string;
@@ -67,6 +77,7 @@ export type APIProviderRequest = {
identity_header_user_id?: string;
identity_header_groups?: string;
skip_tls_verification?: boolean;
metadata_disabled?: boolean;
models: APIProviderModel[];
enabled?: boolean;
};
@@ -84,6 +95,8 @@ export type ProviderConnectInput = {
identityHeaderGroups?: string;
// Skip upstream TLS verification (custom providers with self-signed certs).
skipTlsVerification?: boolean;
// Disable identity metadata injection (user + authorizing group). Off by default.
metadataDisabled?: boolean;
models: ProviderModel[];
enabled?: boolean;
};
@@ -97,6 +110,7 @@ export type ProviderUpdateInput = {
identityHeaderUserId?: string;
identityHeaderGroups?: string;
skipTlsVerification?: boolean;
metadataDisabled?: boolean;
models?: ProviderModel[];
enabled?: boolean;
};
@@ -111,8 +125,10 @@ export type APIAgentNetworkSettings = {
enable_prompt_collection: boolean;
redact_pii: boolean;
access_log_retention_days?: number;
created_at: string;
updated_at: string;
// Absent until the account is bootstrapped — pre-bootstrap the backend
// returns the defaults without a persisted row to date.
created_at?: string;
updated_at?: string;
};
// APIAgentNetworkSettingsRequest matches the PUT /agent-network/settings
@@ -149,6 +165,9 @@ function fromAPI(p: APIProvider): AIProvider {
id: m.id,
inputPer1k: m.input_per_1k,
outputPer1k: m.output_per_1k,
cachedInputPer1k: m.cached_input_per_1k,
cacheReadPer1k: m.cache_read_per_1k,
cacheCreationPer1k: m.cache_creation_per_1k,
}));
return {
id: p.id,
@@ -159,6 +178,7 @@ function fromAPI(p: APIProvider): AIProvider {
identityHeaderUserId: p.identity_header_user_id,
identityHeaderGroups: p.identity_header_groups,
skipTlsVerification: p.skip_tls_verification ?? false,
metadataDisabled: p.metadata_disabled ?? false,
status: p.enabled ? "active" : "disabled",
models,
allowedGroups: [],
@@ -183,10 +203,16 @@ function fromAPI(p: APIProvider): AIProvider {
}
function toAPIModels(models: ProviderModel[]): APIProviderModel[] {
// undefined cache rates stay undefined so JSON.stringify omits the
// key: an omitted rate inherits NetBird's default, an explicit 0
// disables the discount. Coercing here would change billing.
return models.map((m) => ({
id: m.id,
input_per_1k: m.inputPer1k,
output_per_1k: m.outputPer1k,
cached_input_per_1k: m.cachedInputPer1k,
cache_read_per_1k: m.cacheReadPer1k,
cache_creation_per_1k: m.cacheCreationPer1k,
}));
}
@@ -201,6 +227,7 @@ function toCreateRequest(input: ProviderConnectInput): APIProviderRequest {
identity_header_user_id: input.identityHeaderUserId,
identity_header_groups: input.identityHeaderGroups,
skip_tls_verification: input.skipTlsVerification,
metadata_disabled: input.metadataDisabled,
models: toAPIModels(input.models),
enabled: input.enabled,
};
@@ -496,10 +523,12 @@ export function useAIProviders() {
}
// useAgentNetworkSettings fetches the account-level agent-network settings.
// Returns null until the first provider is created — newer backends respond
// 200 + JSON null while no settings row exists; older backends respond 404,
// which we still tolerate via ignoreError so older deploys don't surface
// a spurious error in the empty state.
// Returns null until the account is bootstrapped (first provider create, or
// a settings update carrying a cluster). Backends signal the unbootstrapped
// state differently by age: current ones respond 200 with the defaults and
// an empty cluster/subdomain/endpoint, older ones 200 + JSON null, and the
// oldest 404 — tolerated via ignoreError so old deploys don't surface a
// spurious error in the empty state. All three normalize to null here.
export function useAgentNetworkSettings() {
const { enabled: agentNetworkEnabled } = useAgentNetworkMode();
const { data, error, isLoading, mutate } =
@@ -509,11 +538,14 @@ export function useAgentNetworkSettings() {
true,
agentNetworkEnabled,
);
const settings = useMemo<AgentNetworkSettings | null>(
() => (data ? settingsFromAPI(data) : null),
[data],
);
const notFound = !!error && (error as { code?: number }).code === 404;
// SWR keeps the previous data alongside the error (keepPreviousData), so a
// later 404 must not expose the stale settings; other transient errors keep
// them, which is what keepPreviousData is for.
const settings = useMemo<AgentNetworkSettings | null>(
() => (data && data.endpoint && !notFound ? settingsFromAPI(data) : null),
[data, notFound],
);
return {
settings,
isLoading: isLoading && !notFound,
@@ -647,9 +679,11 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
updates.identityHeaderGroups ?? existing.identity_header_groups,
skip_tls_verification:
updates.skipTlsVerification ?? existing.skip_tls_verification,
metadata_disabled:
updates.metadataDisabled ?? existing.metadata_disabled,
models: updates.models
? toAPIModels(updates.models)
: existing.models ?? [],
: (existing.models ?? []),
enabled: updates.enabled ?? existing.enabled,
};
try {
@@ -734,7 +768,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
guardrail_ids: updates.guardrailIds ?? existing.guardrail_ids ?? [],
limits: updates.limits
? policyLimitsToAPI(updates.limits)
: existing.limits ?? policyLimitsToAPI(EMPTY_POLICY_LIMITS),
: (existing.limits ?? policyLimitsToAPI(EMPTY_POLICY_LIMITS)),
};
try {
await policiesApi.put(merged, `/${id}`);
@@ -26,17 +26,39 @@ export default function AgentAccessLogExpandedRow({ entry }: Readonly<Props>) {
const hasBody = hasPrompt || hasCompletion;
const denyReason = formatDenyReason(entry.denyReason);
const cacheRead = entry.cachedInputTokens ?? 0;
const cacheWrite = entry.cacheCreationTokens ?? 0;
const metadata: Record<string, string> = {
"plg.llm.provider": entry.providerId,
"plg.llm.model": entry.model,
"plg.llm.input_tokens": String(entry.inputTokens),
"plg.llm.output_tokens": String(entry.outputTokens),
"plg.llm.total_tokens": String(entry.inputTokens + entry.outputTokens),
"plg.llm.total_tokens": String(
entry.inputTokens + entry.outputTokens + cacheRead + cacheWrite,
),
"plg.llm.cost_usd": entry.costUsd.toFixed(6),
"plg.llm.stream": entry.stream ? "true" : "false",
"plg.agentnetwork.policy_name": entry.policyName,
"plg.agentnetwork.user_groups": (entry.userGroups ?? []).join(", "),
};
if (cacheRead > 0 || cacheWrite > 0) {
metadata["plg.llm.cached_input_tokens"] = String(cacheRead);
metadata["plg.llm.cache_creation_tokens"] = String(cacheWrite);
metadata["plg.cost.usd_cache"] = (entry.cacheCostUsd ?? 0).toFixed(6);
}
// Per-bucket cost keys mirror the proxy's cost.usd_* metadata. Emitted only
// when the server sent the breakdown, so a row from an older management
// server shows no misleading zeros.
if (entry.inputCostUsd !== undefined || entry.outputCostUsd !== undefined) {
metadata["plg.cost.usd_input"] = (entry.inputCostUsd ?? 0).toFixed(6);
metadata["plg.cost.usd_cached_input"] = (
entry.cachedInputCostUsd ?? 0
).toFixed(6);
metadata["plg.cost.usd_cache_creation"] = (
entry.cacheCreationCostUsd ?? 0
).toFixed(6);
metadata["plg.cost.usd_output"] = (entry.outputCostUsd ?? 0).toFixed(6);
}
if (isDeny) {
metadata["plg.llm_policy.decision"] = "deny";
metadata["plg.llm_policy.reason"] = entry.denyReason ?? "unknown";
Loaded 20 of 38 files, more files were not shown because too many files have changed in this diff. Show more