mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a67a23fc1f |
No files matched your search
@@ -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
@@ -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 && \
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
Generated
+571
-643
File diff suppressed because it is too large.
Load diff
+7
-4
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -57,9 +57,9 @@ export default function DataTableHeader({
|
||||
{children}
|
||||
{sorting &&
|
||||
(column.getIsSorted() === "desc" ? (
|
||||
<IconSortDescending size={16} />
|
||||
) : (
|
||||
<IconSortAscending size={16} />
|
||||
) : (
|
||||
<IconSortDescending size={16} />
|
||||
))}
|
||||
</div>
|
||||
</FullTooltip>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user