mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49582fff74 | ||
|
|
af7745a03b | ||
|
|
19d82cc2ab | ||
|
|
b66cc4ccb7 | ||
|
|
1d408f2e7e | ||
|
|
9fab9d7a83 | ||
|
|
6ca91287fc | ||
|
|
5a5e04ad35 | ||
|
|
598ecb196d | ||
|
|
7c8352822d | ||
|
|
e4f5046ff2 | ||
|
|
d5f61efc1a | ||
|
|
3503141c56 | ||
|
|
a27e4654cf | ||
|
|
b9569f48d5 | ||
|
|
9694bb753d |
No files matched your search
@@ -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
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -57,9 +57,9 @@ export default function DataTableHeader({
|
||||
{children}
|
||||
{sorting &&
|
||||
(column.getIsSorted() === "desc" ? (
|
||||
<IconSortAscending size={16} />
|
||||
) : (
|
||||
<IconSortDescending size={16} />
|
||||
) : (
|
||||
<IconSortAscending size={16} />
|
||||
))}
|
||||
</div>
|
||||
</FullTooltip>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in new issue
Block a user