Compare commits

..
61 changed files with 1407 additions and 2955 deletions

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
{
"extends": ["next/core-web-vitals","prettier"],
"plugins": ["simple-import-sort"],
"rules": {
"simple-import-sort/imports": [
"warn",
{
"groups": [["^\\u0000", "^@?\\w", "^[^.]", "^\\."]]
}
],
"simple-import-sort/exports": "warn"
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24
FROM alpine:3.14
RUN apk add --no-cache bash curl less ca-certificates git tzdata zip gettext \
nginx curl supervisor certbot-nginx && \
-25
View File
@@ -483,28 +483,3 @@ export async function deleteAgentNetworkProvidersByPrefix(
}
}
}
/**
* Whether the management build under test bootstraps the Agent Network
* settings row through the explicit POST /agent-network/settings.
*
* Builds that predate it seed the row as a side effect of the first provider
* create and describe the endpoint identity as cluster + subdomain; builds
* that have it expose proxy_address instead. The settings GET carries the
* field either way — including before the account is bootstrapped, where it
* answers with the defaults and an empty proxy_address — so the shape of that
* response is a side-effect-free probe for the endpoint's existence.
*/
export async function supportsAgentNetworkSettingsBootstrap(
page: Page,
): Promise<boolean> {
const { token, origin } = await getApiContext(page);
const resp = await page.request.get(`${origin}/api/agent-network/settings`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok()) return false;
const settings = await resp.json().catch(() => null);
return (
!!settings && typeof settings === "object" && "proxy_address" in settings
);
}
@@ -1,213 +0,0 @@
/**
* 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();
}
});
});
+2 -40
View File
@@ -10,10 +10,7 @@
* 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 same applies
* to the explicit settings bootstrap: connecting the first provider of an
* account POSTs /agent-network/settings first, so on a build without that
* endpoint the wizard cannot get as far as creating a provider.
* 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),
@@ -25,7 +22,6 @@ import { generateRandomName } from "../helpers/utils";
import {
deleteAgentNetworkProvidersByPrefix,
listAgentNetworkCatalog,
supportsAgentNetworkSettingsBootstrap,
} from "../helpers/api";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
@@ -66,11 +62,6 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
!catalog.some((c) => c.id === KIMI_CATALOG_ID),
`management catalog has no ${KIMI_CATALOG_ID} entry`,
);
test.skip(
!(await supportsAgentNetworkSettingsBootstrap(page)),
"management build has no POST /agent-network/settings, so the " +
"wizard cannot bootstrap the account before the first create",
);
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
@@ -119,40 +110,11 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
resp.request().method() === "POST",
{ timeout: 30_000 },
);
// On the account's first provider the wizard bootstraps the settings row
// first and only creates the provider once that succeeds. A rejected
// bootstrap therefore means no provider POST ever happens, which on its
// own surfaces only as the whole test timing out — race it so the real
// status is reported instead. 409 is not a rejection: it means a
// concurrent bootstrap won and the row exists, which the wizard treats as
// success and follows with the provider create, so matching it here would
// fail a run that is about to succeed.
const bootstrapRejected: Promise<never> = page
.waitForResponse(
(resp) =>
resp.url().includes("/agent-network/settings") &&
resp.request().method() === "POST" &&
resp.status() !== 409 &&
!resp.ok(),
{ timeout: 30_000 },
)
.then(
(resp) => {
throw new Error(
`Agent Network settings bootstrap POST returned ${resp.status()}; ` +
"the provider was never created",
);
},
// Nothing matched: the bootstrap succeeded or wasn't needed. Never
// settle, leaving the race to the provider create.
() => new Promise<never>(() => {}),
);
await page
.getByRole("button", { name: "Connect Provider" })
.last()
.click({ force: true });
const created = await Promise.race([createResponse, bootstrapRejected]);
expect([200, 201]).toContain(created.status());
expect([200, 201]).toContain((await createResponse).status());
// Row lands in the providers table.
await expect(page.getByText(providerName).first()).toBeVisible();
@@ -1,145 +0,0 @@
/**
* 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 endpoint/proxy_address, 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 for an account that has not been
// bootstrapped yet: the mutable settings carry their default values, the
// endpoint/proxy_address identity is empty, and there are no timestamps because
// no row has been persisted.
const UNBOOTSTRAPPED_DEFAULTS = {
endpoint: "",
proxy_address: "",
dedicated: false,
enable_log_collection: true,
enable_prompt_collection: false,
redact_pii: false,
access_log_retention_days: 30,
};
const BOOTSTRAPPED_SETTINGS = {
endpoint: BOOTSTRAPPED_ENDPOINT,
proxy_address: "eu.proxy.netbird.io",
dedicated: false,
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();
}
});
});
+4 -73
View File
@@ -72,19 +72,13 @@ function mockAccounts(page: Page, state: AccountState, delayMs: number) {
async function openOnboarding(
browser: Browser,
opts: {
source?: boolean;
account: AccountState;
delayMs?: number;
edition?: string;
expectOnboarding?: boolean;
},
opts: { source?: boolean; account: AccountState; delayMs?: number },
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
await context.addInitScript(
([sourceKey, sourceValue, withSource, edition]) => {
([sourceKey, sourceValue, withSource]) => {
try {
window.localStorage.setItem("netbird-test-onboarding", "true");
if (withSource) {
@@ -93,32 +87,13 @@ 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,
opts.edition ?? "",
] as const,
[SIGNUP_SOURCE_KEY, AGENT_NETWORK_SOURCE, !!opts.source] as const,
);
const page = await context.newPage();
mockAccounts(page, opts.account, opts.delayMs ?? 0);
// 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;
await loginToApp(page, "owner", { expectOnboarding: true });
return { page, close: () => context.close() };
}
@@ -168,50 +143,6 @@ 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,
}) => {
+15 -48
View File
@@ -1,6 +1,6 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { clearScrollLock, generateRandomName } from "../helpers/utils";
import { generateRandomName } from "../helpers/utils";
import { deleteGroupsByPrefix, deleteSetupKeysByPrefix } from "../helpers/api";
let setupKeys: string[] = [];
@@ -118,42 +118,19 @@ async function createSetupKey(
await expect(page.getByText(opts.name)).toBeVisible();
}
// 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,
) {
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 });
}
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 });
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 });
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes("/api/setup-keys/") && resp.request().method() === "PUT",
{ timeout: 10_000 },
@@ -174,23 +151,13 @@ async function deleteSetupKey(
name: string,
) {
// Row actions are now behind a dropdown menu.
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
.locator("tr")
.filter({ hasText: name })
.getByTestId("setup-key-actions")
.click({ force: true });
await page.getByTestId("delete-setup-key").click({ force: true });
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
@@ -1,122 +0,0 @@
/**
* 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"),
);
});
});
+26 -82
View File
@@ -1,4 +1,3 @@
import type { Page } from "@playwright/test";
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
@@ -21,9 +20,9 @@ test.describe.serial("Team - Service Users @team", () => {
});
test("Should update role and manage access tokens", async ({ dashboardAsOwner: page }) => {
await openServiceUser(page, regularUser);
await page.locator("tr").getByText(regularUser).click();
await changeRoleTo(page, "Admin");
await saveUserChanges(page);
await page.getByTestId("save-changes").click();
// Create and delete access token
const tokenName = generateRandomName("tkn_");
@@ -38,18 +37,23 @@ test.describe.serial("Team - Service Users @team", () => {
await tokenRow.getByTestId("access-token-delete").click();
await page.getByTestId("confirmation.confirm").click();
await expect(tokenRow).not.toBeVisible();
await page.getByText("Service Users").first().click();
});
test("Should update admin user role and verify all changes persisted", async ({
dashboardAsOwner: page,
}) => {
await openServiceUser(page, adminServiceUser);
await page.locator("tr").getByText(adminServiceUser).click();
await changeRoleTo(page, "User");
await saveUserChanges(page);
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;
// Go back the way a user would, so this also covers the save refreshing
// the cached users list rather than only what the server persisted.
await returnToServiceUserList(page);
await page.getByText("Service Users").first().click();
await checkServiceUserRow(page, regularUser, "Admin");
await checkServiceUserRow(page, adminServiceUser, "User");
@@ -60,7 +64,6 @@ test.describe.serial("Team - Service Users @team", () => {
});
test("Should delete service users", async ({ dashboardAsOwner: page }) => {
await openServiceUserList(page);
for (const name of [regularUser, adminServiceUser]) {
const row = page.locator("tr").filter({ hasText: name });
// Row actions are now behind a dropdown menu; open it, then delete.
@@ -72,7 +75,11 @@ test.describe.serial("Team - Service Users @team", () => {
});
});
async function createServiceUser(page: Page, name: string, role: string) {
async function createServiceUser(
page: import("@playwright/test").Page,
name: string,
role: string,
) {
await page.getByTestId("open-service-user-modal").click();
await expect(page.getByTestId("service-user-name")).toBeVisible({ timeout: 5_000 });
await page.getByTestId("service-user-name").fill(name);
@@ -86,86 +93,23 @@ async function createServiceUser(page: Page, name: string, role: string) {
await expect(page.getByTestId("service-user-name")).not.toBeVisible({ timeout: 5_000 });
}
// openServiceUserList lands on the service users table and waits for it to
// settle.
async function openServiceUserList(page: Page) {
await navigateTo(page, "/team/service-users");
await expectServiceUserList(page);
}
// returnToServiceUserList goes back to the list from a user's page the way a
// user would, via the breadcrumb. Unlike a plain click on the nav entry, it
// waits for the navigation to actually land: the click itself resolves while
// the client-side navigation is still in flight, so anything that follows can
// otherwise be computed against the page being left behind.
async function returnToServiceUserList(page: Page) {
await page
.getByTestId("breadcrumb-item")
.filter({ hasText: "Service Users" })
.click();
await page.waitForURL(/\/team\/service-users/, { timeout: 15_000 });
await expectServiceUserList(page);
}
async function expectServiceUserList(page: Page) {
await expect(page.getByRole("heading", { name: /Service Users/ })).toBeVisible({
timeout: 10_000,
});
}
// openServiceUser opens one service user's page from the list and only returns
// once that page really belongs to `name`. The row click is a client-side
// navigation and the table re-renders as the users fetch revalidates, so
// clicking a row without confirming where we landed can silently leave us on a
// different user — and every later edit then targets that other user.
async function openServiceUser(page: Page, name: string) {
await openServiceUserList(page);
const row = page.locator("tr").filter({ hasText: name });
await expect(row).toBeVisible({ timeout: 10_000 });
await row.click();
await page.waitForURL(/\/team\/user\?/, { timeout: 15_000 });
await expect(page.getByRole("heading", { name, exact: true })).toBeVisible({
timeout: 10_000,
});
}
async function checkServiceUserRow(page: Page, name: string, role: string) {
async function checkServiceUserRow(
page: import("@playwright/test").Page,
name: string,
role: string,
) {
const row = page.locator("tr").filter({ hasText: name });
await expect(row).toBeVisible({ timeout: 10_000 });
await expect(row.getByText(role, { exact: true }).first()).toBeVisible({ timeout: 10_000 });
}
async function changeRoleTo(page: Page, role: string) {
async function changeRoleTo(
page: import("@playwright/test").Page,
role: string,
) {
await page.getByTestId("user-role-selector").click();
await page
.getByTestId("user-role-selector-item")
.getByText(role, { exact: true })
.click();
// Confirm the pick landed. A missed dropdown click leaves the role
// unchanged, which disables "Save Changes" and turns the save below into an
// unexplained 30s wait for a PUT that is never sent.
await expect(
page.getByTestId("user-role-selector").getByText(role, { exact: true }),
).toBeVisible({ timeout: 5_000 });
}
// saveUserChanges saves the open user page and awaits the PUT, so the change is
// persisted before the next serial test asserts it — clicking save alone
// returns before the request lands. The PUT is pinned to the user currently
// open and its status checked: a PUT for some other user, or a rejected one,
// otherwise leaves the account in a state later assertions blame on the UI.
async function saveUserChanges(page: Page) {
const userId = new URL(page.url()).searchParams.get("id");
expect(userId, "expected a user page with an id parameter").toBeTruthy();
const saveResponse = page.waitForResponse(
(resp) =>
resp.url().includes(`/api/users/${userId}`) &&
resp.request().method() === "PUT",
{ timeout: 30_000 },
);
await page.getByTestId("save-changes").click();
expect((await saveResponse).status()).toBe(200);
}
-31
View File
@@ -1,31 +0,0 @@
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import prettier from "eslint-config-prettier";
import simpleImportSort from "eslint-plugin-simple-import-sort";
const config = [
{
ignores: [
".claude/**",
"playwright-report/**",
"test-logs/**",
"public/**",
],
},
...nextCoreWebVitals,
prettier,
{
files: ["**/*.{js,jsx,mjs,ts,tsx,mts,cts}"],
plugins: { "simple-import-sort": simpleImportSort },
rules: {
"simple-import-sort/imports": [
"warn",
{
groups: [["^\\u0000", "^@?\\w", "^[^.]", "^\\."]],
},
],
"simple-import-sort/exports": "warn",
},
},
];
export default config;
+571 -643
View File
File diff suppressed because it is too large. Load diff
+7 -4
View File
@@ -9,10 +9,11 @@
"copy": "copyfiles -f ./node_modules/@axa-fr/react-oidc/dist/OidcServiceWorker.js ./public",
"copytrusted": "copyfiles -f ./public/local/OidcTrustedDomains.js ./public",
"dev": "next dev -p 3000",
"turbo": "next dev -p 3000 --turbo",
"build": "next build",
"postbuild": "node postbuild.js",
"start": "next start",
"lint": "eslint .",
"lint": "next lint",
"test:setup": "cd ./e2e/environment && sh create-test-env.sh",
"test:clean": "cd ./e2e/environment && sh clean-test-env.sh",
"test:dev": "cross-env APP_ENV=test next dev -p 1337",
@@ -70,10 +71,12 @@
"eslint-plugin-simple-import-sort": "^10.0.0",
"framer-motion": "^12.29.2",
"ip-address": "^10.2.0",
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.7",
"lodash": "4.18.1",
"lucide-react": "^0.566.0",
"next": "16.3.0",
"next": "16.1.7",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
"react": "^19.2.4",
"react-chartjs-2": "^5.3.0",
@@ -100,11 +103,11 @@
},
"devDependencies": {
"@faker-js/faker": "^9.5.1",
"@playwright/test": "^1.52.0",
"@types/chroma-js": "^3.1.1",
"@types/js-cookie": "^3.0.6",
"@playwright/test": "^1.52.0",
"eslint": "^9.39.1",
"eslint-config-next": "^16.3.0",
"eslint-config-next": "^16.1.6",
"postcss": "^8",
"prettier": "3.0.3",
"tailwindcss": "^3.4.17"
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

+37 -38
View File
@@ -7,7 +7,6 @@ import * as React from "react";
import Skeleton from "react-loading-skeleton";
import useFetchApi from "@utils/api";
import { isNetBirdCloud } from "@utils/netbird";
import { isNewerVersion } from "@utils/version";
import { useApplicationContext } from "@/contexts/ApplicationProvider";
import { VersionInfo as VersionInfoType } from "@/interfaces/Instance";
@@ -18,14 +17,29 @@ function formatVersion(version: string): string {
return version;
}
// Builds can carry a suffix that overflows the sidebar: semver build metadata
// ("0.77.0+enterprise.1" on enterprise builds) or a numeric CI build number
// ("0.76.3-31256681241"). Show the release only and keep the full string for the
// tooltip. Pre-release labels like "-rc.1" are left intact so they stay visible.
function formatShortVersion(version: string): string {
return formatVersion(version)
.replace(/\+.*$/, "")
.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;
if (current === "development") return false;
// Strip "v" prefix if present
const normalizedCurrent = current.replace(/^v/, "");
const normalizedLatest = latest.replace(/^v/, "");
const currentParts = normalizedCurrent
.split(".")
.map((p) => parseInt(p, 10) || 0);
const latestParts = normalizedLatest
.split(".")
.map((p) => parseInt(p, 10) || 0);
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
const c = currentParts[i] || 0;
const l = latestParts[i] || 0;
if (l > c) return true;
if (l < c) return false;
}
return false;
}
export const NavigationVersionInfo = () => {
@@ -63,18 +77,12 @@ const NavigationVersionInfoContent = () => {
if (!versionInfo) return null;
// Prefer the server's verdict: it knows the release channel the installation
// runs on and compares with a full semver implementation. Fall back to a local
// comparison for management servers that don't report the flag yet.
const managementUpdateAvailable =
versionInfo.management_update_available ??
isNewerVersion(
versionInfo.management_current_version,
versionInfo.management_available_version,
);
// The dashboard's installed version is baked in at build time and the server
// never sees it, so this one is always compared here.
const dashboardUpdateAvailable = isNewerVersion(
// Compare versions to detect updates (returns false for "development" versions)
const managementUpdateAvailable = compareVersions(
versionInfo.management_current_version,
versionInfo.management_available_version,
);
const dashboardUpdateAvailable = compareVersions(
dashboardVersion,
versionInfo.dashboard_available_version,
);
@@ -90,15 +98,9 @@ const NavigationVersionInfoContent = () => {
<div className="flex flex-col gap-1 text-nb-gray-400">
<FullTooltip
content={
<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>
<span className="text-xs">
Latest: {formatVersion(versionInfo.management_available_version)}
</span>
}
side="top"
className="w-full"
@@ -106,18 +108,15 @@ const NavigationVersionInfoContent = () => {
<div className="flex items-center justify-between w-full cursor-default">
<span>Management</span>
<span className="text-nb-gray-300 font-medium">
{formatShortVersion(versionInfo.management_current_version)}
{formatVersion(versionInfo.management_current_version)}
</span>
</div>
</FullTooltip>
<FullTooltip
content={
<div className="text-xs flex flex-col gap-1">
<span>Installed: {formatVersion(dashboardVersion)}</span>
<span>
Latest: {formatVersion(versionInfo.dashboard_available_version)}
</span>
</div>
<span className="text-xs">
Latest: {formatVersion(versionInfo.dashboard_available_version)}
</span>
}
side="top"
className="w-full"
@@ -125,7 +124,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">
{formatShortVersion(dashboardVersion)}
{formatVersion(dashboardVersion)}
</span>
</div>
</FullTooltip>
+2 -2
View File
@@ -57,9 +57,9 @@ export default function DataTableHeader({
{children}
{sorting &&
(column.getIsSorted() === "desc" ? (
<IconSortDescending size={16} />
) : (
<IconSortAscending size={16} />
) : (
<IconSortDescending size={16} />
))}
</div>
</FullTooltip>
+42 -2
View File
@@ -6,6 +6,26 @@ import { createElement, useMemo } from "react";
import RoundedFlag from "@/assets/countries/RoundedFlag";
import { useCountries } from "@/contexts/CountryProvider";
// browserCountryCode returns the ISO 3166-1 alpha-2 region from the browser's
// preferred languages (e.g. "en-US" -> "US"), or undefined when none carries a
// region. It reads navigator.languages, so it is client-only.
function browserCountryCode(): string | undefined {
if (typeof navigator === "undefined") return undefined;
const langs = navigator.languages?.length
? navigator.languages
: [navigator.language];
for (const lang of langs) {
if (!lang) continue;
try {
const region = new Intl.Locale(lang).region;
if (region) return region.toUpperCase();
} catch {
// Ignore malformed language tags and try the next one.
}
}
return undefined;
}
type Props = {
value: string;
onChange: (value: string) => void;
@@ -13,7 +33,13 @@ type Props = {
popoverWidth?: "auto" | "content" | number;
truncate?: boolean;
};
export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth, truncate }: Props) => {
export const CountrySelector = ({
value,
onChange,
iconSize = 20,
popoverWidth,
truncate,
}: Props) => {
const { countries, isLoading } = useCountries();
const countryList = useMemo(() => {
@@ -36,6 +62,20 @@ export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth,
}) as SelectOption[];
}, [countries]);
// Surface the browser-detected country at the top so the common case is one
// click away. Falls back to the original order when detection fails or the
// code is not in the list.
const orderedList = useMemo(() => {
if (!countryList?.length) return countryList;
const code = browserCountryCode();
if (!code) return countryList;
const index = countryList.findIndex((option) => option.value === code);
if (index <= 0) return countryList;
const reordered = countryList.slice();
const [detected] = reordered.splice(index, 1);
return [detected, ...reordered];
}, [countryList]);
return (
<div className={"block w-full"}>
<SelectDropdown
@@ -46,7 +86,7 @@ export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth,
value={value}
onChange={onChange}
iconSize={iconSize}
options={countryList || []}
options={orderedList || []}
popoverWidth={popoverWidth}
truncate={truncate}
/>
+1 -1
View File
@@ -8,8 +8,8 @@ import {
DropdownMenuTrigger,
} from "@components/DropdownMenu";
import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { useTheme } from "@/contexts/ThemeProvider";
export default function DarkModeToggle() {
const [mounted, setMounted] = useState(false);
+32
View File
@@ -0,0 +1,32 @@
"use client";
import "react-loading-skeleton/dist/skeleton.css";
import dynamic from "next/dynamic";
import { type ThemeProviderProps } from "next-themes/dist/types";
import * as React from "react";
import { SkeletonTheme } from "react-loading-skeleton";
const NextThemesProvider = dynamic(
() => import("next-themes").then((mod) => mod.ThemeProvider),
{ ssr: false },
);
export function GlobalThemeProvider({
children,
...props
}: ThemeProviderProps) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="dark"
storageKey="netbird-theme"
enableSystem={false}
disableTransitionOnChange
{...props}
>
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
{children}
</SkeletonTheme>
</NextThemesProvider>
);
}
-108
View File
@@ -1,108 +0,0 @@
"use client";
import "react-loading-skeleton/dist/skeleton.css";
import React, { useCallback, useContext, useEffect, useState } from "react";
import { SkeletonTheme } from "react-loading-skeleton";
export type Theme = "dark" | "light" | "system";
export type ResolvedTheme = Exclude<Theme, "system">;
const STORAGE_KEY = "netbird-theme";
const DEFAULT_THEME: ResolvedTheme = "dark";
const THEME_CLASSES: ResolvedTheme[] = ["dark", "light"];
const SKELETON_COLORS: Record<
ResolvedTheme,
{ base: string; highlight: string }
> = {
dark: { base: "#25282d", highlight: "#33373e" },
light: { base: "#e4e7e9", highlight: "#f4f6f7" },
};
type Props = {
children: React.ReactNode;
};
const ThemeContext = React.createContext(
{} as {
theme: Theme;
setTheme: (theme: Theme) => void;
},
);
function isTheme(value: string | null): value is Theme {
return value === "dark" || value === "light" || value === "system";
}
function systemTheme(): ResolvedTheme {
return window.matchMedia("(prefers-color-scheme: light)").matches
? "light"
: "dark";
}
function resolveTheme(theme: Theme): ResolvedTheme {
return theme === "system" ? systemTheme() : theme;
}
// storedTheme reads the persisted preference. Access to localStorage can throw
// when the browser blocks storage, in which case we fall back to the default.
function storedTheme(): Theme {
if (typeof window === "undefined") return DEFAULT_THEME;
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return isTheme(stored) ? stored : DEFAULT_THEME;
} catch {
return DEFAULT_THEME;
}
}
export function ThemeProvider({ children }: Props) {
// The theme class ships on <html> in the static export, so the initial paint
// never flashes. Reading storage here only matters when the stored preference
// differs from that default.
const [theme, setThemeState] = useState<Theme>(storedTheme);
// Tracked separately so "system" keeps the skeleton palette in step with the
// OS preference, which can change while the app is open.
const [resolvedTheme, setResolvedTheme] =
useState<ResolvedTheme>(DEFAULT_THEME);
const setTheme = useCallback((next: Theme) => {
setThemeState(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Preference is not persisted when storage is unavailable.
}
}, []);
useEffect(() => {
const apply = () => {
const resolved = resolveTheme(theme);
const root = document.documentElement;
root.classList.remove(...THEME_CLASSES.filter((c) => c !== resolved));
root.classList.add(resolved);
root.style.colorScheme = resolved;
setResolvedTheme(resolved);
};
apply();
if (theme !== "system") return;
const query = window.matchMedia("(prefers-color-scheme: light)");
query.addEventListener("change", apply);
return () => query.removeEventListener("change", apply);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<SkeletonTheme
baseColor={SKELETON_COLORS[resolvedTheme].base}
highlightColor={SKELETON_COLORS[resolvedTheme].highlight}
>
{children}
</SkeletonTheme>
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
Loaded 20 of 61 files, more files were not shown because too many files have changed in this diff. Show more