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
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -22,15 +22,7 @@ test.describe.serial("Team - Service Users @team", () => {
|
||||
test("Should update role and manage access tokens", async ({ dashboardAsOwner: page }) => {
|
||||
await page.locator("tr").getByText(regularUser).click();
|
||||
await changeRoleTo(page, "Admin");
|
||||
// Await the PUT so the role change is persisted before the next serial
|
||||
// test asserts it — clicking save alone returns before the request lands.
|
||||
const saveResponse = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/api/users/") && resp.request().method() === "PUT",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("save-changes").click();
|
||||
await saveResponse;
|
||||
|
||||
// Create and delete access token
|
||||
const tokenName = generateRandomName("tkn_");
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -33,12 +33,20 @@ export const CrowdSecMode = {
|
||||
|
||||
export type CrowdSecMode = (typeof CrowdSecMode)[keyof typeof CrowdSecMode];
|
||||
|
||||
export const AllowMatch = {
|
||||
ALL: "all",
|
||||
ANY: "any",
|
||||
} as const;
|
||||
|
||||
export type AllowMatch = (typeof AllowMatch)[keyof typeof AllowMatch];
|
||||
|
||||
export interface AccessRestrictions {
|
||||
allowed_cidrs?: string[];
|
||||
blocked_cidrs?: string[];
|
||||
allowed_countries?: string[];
|
||||
blocked_countries?: string[];
|
||||
crowdsec_mode?: CrowdSecMode;
|
||||
allow_match?: AllowMatch;
|
||||
}
|
||||
|
||||
export interface ReverseProxyMeta {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -27,13 +27,6 @@ export type APIProviderModel = {
|
||||
id: string;
|
||||
input_per_1k: number;
|
||||
output_per_1k: number;
|
||||
// Optional cache rates. Omitted on the wire = inherit NetBird's
|
||||
// default rate for this model (the backend folds it in at synthesis
|
||||
// time); explicit 0 = no discount (bucket bills at the input rate).
|
||||
// Never coerce undefined to 0 — the two mean different things.
|
||||
cached_input_per_1k?: number;
|
||||
cache_read_per_1k?: number;
|
||||
cache_creation_per_1k?: number;
|
||||
};
|
||||
|
||||
export type APIProvider = {
|
||||
@@ -163,9 +156,6 @@ function fromAPI(p: APIProvider): AIProvider {
|
||||
id: m.id,
|
||||
inputPer1k: m.input_per_1k,
|
||||
outputPer1k: m.output_per_1k,
|
||||
cachedInputPer1k: m.cached_input_per_1k,
|
||||
cacheReadPer1k: m.cache_read_per_1k,
|
||||
cacheCreationPer1k: m.cache_creation_per_1k,
|
||||
}));
|
||||
return {
|
||||
id: p.id,
|
||||
@@ -201,16 +191,10 @@ function fromAPI(p: APIProvider): AIProvider {
|
||||
}
|
||||
|
||||
function toAPIModels(models: ProviderModel[]): APIProviderModel[] {
|
||||
// undefined cache rates stay undefined so JSON.stringify omits the
|
||||
// key: an omitted rate inherits NetBird's default, an explicit 0
|
||||
// disables the discount. Coercing here would change billing.
|
||||
return models.map((m) => ({
|
||||
id: m.id,
|
||||
input_per_1k: m.inputPer1k,
|
||||
output_per_1k: m.outputPer1k,
|
||||
cached_input_per_1k: m.cachedInputPer1k,
|
||||
cache_read_per_1k: m.cacheReadPer1k,
|
||||
cache_creation_per_1k: m.cacheCreationPer1k,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -26,39 +26,17 @@ export default function AgentAccessLogExpandedRow({ entry }: Readonly<Props>) {
|
||||
const hasBody = hasPrompt || hasCompletion;
|
||||
const denyReason = formatDenyReason(entry.denyReason);
|
||||
|
||||
const cacheRead = entry.cachedInputTokens ?? 0;
|
||||
const cacheWrite = entry.cacheCreationTokens ?? 0;
|
||||
const metadata: Record<string, string> = {
|
||||
"plg.llm.provider": entry.providerId,
|
||||
"plg.llm.model": entry.model,
|
||||
"plg.llm.input_tokens": String(entry.inputTokens),
|
||||
"plg.llm.output_tokens": String(entry.outputTokens),
|
||||
"plg.llm.total_tokens": String(
|
||||
entry.inputTokens + entry.outputTokens + cacheRead + cacheWrite,
|
||||
),
|
||||
"plg.llm.total_tokens": String(entry.inputTokens + entry.outputTokens),
|
||||
"plg.llm.cost_usd": entry.costUsd.toFixed(6),
|
||||
"plg.llm.stream": entry.stream ? "true" : "false",
|
||||
"plg.agentnetwork.policy_name": entry.policyName,
|
||||
"plg.agentnetwork.user_groups": (entry.userGroups ?? []).join(", "),
|
||||
};
|
||||
if (cacheRead > 0 || cacheWrite > 0) {
|
||||
metadata["plg.llm.cached_input_tokens"] = String(cacheRead);
|
||||
metadata["plg.llm.cache_creation_tokens"] = String(cacheWrite);
|
||||
metadata["plg.cost.usd_cache"] = (entry.cacheCostUsd ?? 0).toFixed(6);
|
||||
}
|
||||
// Per-bucket cost keys mirror the proxy's cost.usd_* metadata. Emitted only
|
||||
// when the server sent the breakdown, so a row from an older management
|
||||
// server shows no misleading zeros.
|
||||
if (entry.inputCostUsd !== undefined || entry.outputCostUsd !== undefined) {
|
||||
metadata["plg.cost.usd_input"] = (entry.inputCostUsd ?? 0).toFixed(6);
|
||||
metadata["plg.cost.usd_cached_input"] = (
|
||||
entry.cachedInputCostUsd ?? 0
|
||||
).toFixed(6);
|
||||
metadata["plg.cost.usd_cache_creation"] = (
|
||||
entry.cacheCreationCostUsd ?? 0
|
||||
).toFixed(6);
|
||||
metadata["plg.cost.usd_output"] = (entry.outputCostUsd ?? 0).toFixed(6);
|
||||
}
|
||||
if (isDeny) {
|
||||
metadata["plg.llm_policy.decision"] = "deny";
|
||||
metadata["plg.llm_policy.reason"] = entry.denyReason ?? "unknown";
|
||||
|
||||
@@ -261,14 +261,13 @@ export default function AgentAccessLogTable({
|
||||
</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<CostCell
|
||||
costUsd={row.original.costUsd}
|
||||
cacheCostUsd={row.original.cacheCostUsd}
|
||||
inputCostUsd={row.original.inputCostUsd}
|
||||
cachedInputCostUsd={row.original.cachedInputCostUsd}
|
||||
cacheCreationCostUsd={row.original.cacheCreationCostUsd}
|
||||
outputCostUsd={row.original.outputCostUsd}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
"text-nb-gray-300 text-[0.82rem] px-3 py-2 font-mono whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
${row.original.costUsd.toFixed(4)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -410,8 +409,6 @@ export default function AgentAccessLogTable({
|
||||
{
|
||||
inputTokens: row.original.inputTokens,
|
||||
outputTokens: row.original.outputTokens,
|
||||
cachedInputTokens: row.original.cachedInputTokens,
|
||||
cacheCreationTokens: row.original.cacheCreationTokens,
|
||||
} as AIAccessLogEntry
|
||||
}
|
||||
/>
|
||||
@@ -426,14 +423,13 @@ export default function AgentAccessLogTable({
|
||||
</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<CostCell
|
||||
costUsd={row.original.costUsd}
|
||||
cacheCostUsd={row.original.cacheCostUsd}
|
||||
inputCostUsd={row.original.inputCostUsd}
|
||||
cachedInputCostUsd={row.original.cachedInputCostUsd}
|
||||
cacheCreationCostUsd={row.original.cacheCreationCostUsd}
|
||||
outputCostUsd={row.original.outputCostUsd}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
"text-nb-gray-300 text-[0.82rem] px-3 py-2 font-mono whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
${row.original.costUsd.toFixed(4)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -445,13 +441,12 @@ export default function AgentAccessLogTable({
|
||||
</DataTableHeader>
|
||||
),
|
||||
// Same Reason cell as the flat view (deny reason, or the authorising
|
||||
// policy link). Prefer an allowed request so a session that succeeded
|
||||
// shows the authorising policy rather than an incidental deny reason;
|
||||
// only surface a deny reason when every request was denied.
|
||||
// policy link). A session is one policy decision in practice; surface a
|
||||
// denied request when present, otherwise the first entry.
|
||||
cell: ({ row }) => {
|
||||
const entries = row.original.entries;
|
||||
const representative =
|
||||
entries.find((e) => e.decision === "allow") ?? entries[0];
|
||||
entries.find((e) => e.decision === "deny") ?? entries[0];
|
||||
return representative ? (
|
||||
<ReasonCell entry={representative} />
|
||||
) : (
|
||||
@@ -997,210 +992,64 @@ function ProviderCell({
|
||||
);
|
||||
}
|
||||
|
||||
// TokenBreakdown is the hover content shared by the flat Tokens column and the
|
||||
// session's per-request rows: one line per token bucket plus a total. Buckets
|
||||
// default to 0 so it renders for denied requests that carry partial counts.
|
||||
function TokenBreakdown({ entry }: { entry: AIAccessLogEntry }) {
|
||||
const cacheRead = entry.cachedInputTokens ?? 0;
|
||||
const cacheWrite = entry.cacheCreationTokens ?? 0;
|
||||
// Anthropic-shape cache buckets are additive to input tokens, so they count toward the total.
|
||||
const total =
|
||||
(entry.inputTokens ?? 0) +
|
||||
(entry.outputTokens ?? 0) +
|
||||
cacheRead +
|
||||
cacheWrite;
|
||||
return (
|
||||
<div className={"text-xs flex flex-col gap-1"}>
|
||||
<div className={"flex items-center gap-2 whitespace-nowrap"}>
|
||||
<span className={"font-medium"}>
|
||||
{(entry.inputTokens ?? 0).toLocaleString()}
|
||||
</span>
|
||||
<span className={"text-nb-gray-400"}>input</span>
|
||||
</div>
|
||||
<div className={"flex items-center gap-2 whitespace-nowrap"}>
|
||||
<span className={"font-medium"}>
|
||||
{(entry.outputTokens ?? 0).toLocaleString()}
|
||||
</span>
|
||||
<span className={"text-nb-gray-400"}>output</span>
|
||||
</div>
|
||||
<div className={"flex items-center gap-2 whitespace-nowrap"}>
|
||||
<span className={"font-medium"}>{cacheRead.toLocaleString()}</span>
|
||||
<span className={"text-nb-gray-400"}>cache read</span>
|
||||
</div>
|
||||
<div className={"flex items-center gap-2 whitespace-nowrap"}>
|
||||
<span className={"font-medium"}>{cacheWrite.toLocaleString()}</span>
|
||||
<span className={"text-nb-gray-400"}>cache write</span>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"border-t border-nb-gray-800 mt-0.5 pt-1 flex items-center gap-2 text-nb-gray-400 whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
<span className={"font-medium text-nb-gray-200"}>
|
||||
{total.toLocaleString()}
|
||||
</span>
|
||||
<span>total</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokensCell({ entry }: { entry: AIAccessLogEntry }) {
|
||||
// Cache-only requests carry no input/output but real cache read/write tokens,
|
||||
// so weigh all four buckets — matching TokenBreakdown's total.
|
||||
if (
|
||||
(entry.inputTokens ?? 0) === 0 &&
|
||||
(entry.outputTokens ?? 0) === 0 &&
|
||||
(entry.cachedInputTokens ?? 0) === 0 &&
|
||||
(entry.cacheCreationTokens ?? 0) === 0
|
||||
(entry.inputTokens === undefined || entry.inputTokens === 0) &&
|
||||
(entry.outputTokens === undefined || entry.outputTokens === 0)
|
||||
) {
|
||||
return <EmptyRow />;
|
||||
}
|
||||
const total = (entry.inputTokens ?? 0) + (entry.outputTokens ?? 0);
|
||||
return (
|
||||
<FullTooltip content={<TokenBreakdown entry={entry} />}>
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"text-xs flex flex-col gap-1"}>
|
||||
<div className={"flex items-center gap-2 whitespace-nowrap"}>
|
||||
<ArrowUpIcon size={12} className={"text-sky-400 shrink-0"} />
|
||||
<span className={"font-medium"}>
|
||||
{entry.inputTokens.toLocaleString()}
|
||||
</span>
|
||||
<span className={"text-nb-gray-400"}>input</span>
|
||||
</div>
|
||||
<div className={"flex items-center gap-2 whitespace-nowrap"}>
|
||||
<ArrowDownIcon size={12} className={"text-netbird shrink-0"} />
|
||||
<span className={"font-medium"}>
|
||||
{entry.outputTokens.toLocaleString()}
|
||||
</span>
|
||||
<span className={"text-nb-gray-400"}>output</span>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"border-t border-nb-gray-800 mt-0.5 pt-1 flex items-center gap-2 text-nb-gray-400 whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
<span className={"w-3 shrink-0"} aria-hidden={true} />
|
||||
<span className={"font-medium text-nb-gray-200"}>
|
||||
{total.toLocaleString()}
|
||||
</span>
|
||||
<span>total</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={"flex flex-col text-xs gap-1 text-nb-gray-300 font-medium"}
|
||||
>
|
||||
<div className={"flex gap-2 items-center whitespace-nowrap"}>
|
||||
<ArrowUpIcon size={15} className={"text-sky-400"} />
|
||||
<span className={"sr-only"}>Input:</span>
|
||||
{(entry.inputTokens ?? 0).toLocaleString()}
|
||||
{entry.inputTokens.toLocaleString()}
|
||||
</div>
|
||||
<div className={"flex gap-2 items-center whitespace-nowrap"}>
|
||||
<ArrowDownIcon size={15} className={"text-netbird"} />
|
||||
<span className={"sr-only"}>Output:</span>
|
||||
{(entry.outputTokens ?? 0).toLocaleString()}
|
||||
{entry.outputTokens.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</FullTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// CostRow is one line of the Cost hover breakdown: an amount plus its label.
|
||||
function CostRow({ amount, label }: { amount: number; label: string }) {
|
||||
return (
|
||||
<div className={"flex items-center gap-2 whitespace-nowrap"}>
|
||||
<span className={"font-medium"}>${amount.toFixed(4)}</span>
|
||||
<span className={"text-nb-gray-400 font-sans"}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CostFields = {
|
||||
costUsd: number;
|
||||
cacheCostUsd?: number;
|
||||
inputCostUsd?: number;
|
||||
cachedInputCostUsd?: number;
|
||||
cacheCreationCostUsd?: number;
|
||||
outputCostUsd?: number;
|
||||
};
|
||||
|
||||
// hasCostBreakdown reports whether a request carries enough cost detail to be
|
||||
// worth a hover: a cache split or a per-bucket breakdown. Shared so the flat
|
||||
// Cost cell and the session rows attach the tooltip on the same condition.
|
||||
function hasCostBreakdown(f: CostFields): boolean {
|
||||
const cache = f.cacheCostUsd ?? 0;
|
||||
const perBucket = f.inputCostUsd !== undefined || f.outputCostUsd !== undefined;
|
||||
return cache > 0 || perBucket;
|
||||
}
|
||||
|
||||
// CostBreakdown is the hover content shared by the flat Cost column and the
|
||||
// session's per-request rows.
|
||||
//
|
||||
// Servers that send the per-bucket breakdown get one line per bucket the
|
||||
// provider bills separately (input / output / cache read / cache write). Older
|
||||
// servers send only the two aggregates, so the hover falls back to the coarse
|
||||
// "input + output" vs "cache" split derivable from those — the two shapes are
|
||||
// distinguished by whether inputCostUsd is defined, not by whether it is zero.
|
||||
function CostBreakdown({
|
||||
costUsd,
|
||||
cacheCostUsd,
|
||||
inputCostUsd,
|
||||
cachedInputCostUsd,
|
||||
cacheCreationCostUsd,
|
||||
outputCostUsd,
|
||||
}: CostFields) {
|
||||
const cache = cacheCostUsd ?? 0;
|
||||
const hasBreakdown =
|
||||
inputCostUsd !== undefined || outputCostUsd !== undefined;
|
||||
const cacheRead = cachedInputCostUsd ?? 0;
|
||||
const cacheWrite = cacheCreationCostUsd ?? 0;
|
||||
return (
|
||||
<div className={"text-xs flex flex-col gap-1 font-mono"}>
|
||||
{hasBreakdown ? (
|
||||
<>
|
||||
{/* All four buckets, including zeros: a zero cache-read line is
|
||||
information (the request missed the cache), and a fixed set of
|
||||
rows keeps the hover comparable between requests. */}
|
||||
<CostRow amount={inputCostUsd ?? 0} label={"input"} />
|
||||
<CostRow amount={outputCostUsd ?? 0} label={"output"} />
|
||||
<CostRow amount={cacheRead} label={"cache read"} />
|
||||
<CostRow amount={cacheWrite} label={"cache write"} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CostRow amount={costUsd - cache} label={"input + output"} />
|
||||
<CostRow amount={cache} label={"cache"} />
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
"border-t border-nb-gray-800 mt-0.5 pt-1 flex items-center gap-2 text-nb-gray-400 whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
<span className={"font-medium text-nb-gray-200"}>
|
||||
${costUsd.toFixed(4)}
|
||||
</span>
|
||||
<span className={"font-sans"}>total</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// CostCell renders the metered USD cost with a hover breakdown of the buckets
|
||||
// it was billed from (see CostBreakdown).
|
||||
function CostCell(fields: CostFields) {
|
||||
const {
|
||||
costUsd,
|
||||
cacheCostUsd,
|
||||
inputCostUsd,
|
||||
cachedInputCostUsd,
|
||||
cacheCreationCostUsd,
|
||||
outputCostUsd,
|
||||
} = fields;
|
||||
const cache = cacheCostUsd ?? 0;
|
||||
|
||||
// Nothing was metered: the request never reached a provider (denied before
|
||||
// routing), or ran on a model the proxy deliberately doesn't price. A dash
|
||||
// reads as "not metered" — matching TokensCell — where "$0.0000" plus a
|
||||
// tooltip of zeros would imply a priced request that happened to be free.
|
||||
const buckets = [
|
||||
inputCostUsd ?? 0,
|
||||
cachedInputCostUsd ?? 0,
|
||||
cacheCreationCostUsd ?? 0,
|
||||
outputCostUsd ?? 0,
|
||||
];
|
||||
if (costUsd === 0 && cache === 0 && buckets.every((b) => b === 0)) {
|
||||
return <EmptyRow />;
|
||||
}
|
||||
|
||||
const display = (
|
||||
<span
|
||||
className={
|
||||
"text-nb-gray-300 text-[0.82rem] px-3 py-2 font-mono whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
${costUsd.toFixed(4)}
|
||||
</span>
|
||||
);
|
||||
// Nothing to break out: no cache spend and no per-bucket split to show.
|
||||
if (!hasCostBreakdown(fields)) return display;
|
||||
|
||||
return (
|
||||
<FullTooltip content={<CostBreakdown {...fields} />}>{display}</FullTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// resolveProviderFn resolves a request's configured provider (by the router's
|
||||
// stamped resolved_provider_id) — shared by the session cells so they reuse the
|
||||
// flat row's provider resolution.
|
||||
@@ -1355,13 +1204,7 @@ function SessionEntriesRow({ session }: { session: AIAccessLogSession }) {
|
||||
>
|
||||
{[...session.entries].reverse().map((entry) => {
|
||||
const isOpen = open.includes(entry.id);
|
||||
// Sum all four buckets so cache-only requests count and the figure
|
||||
// matches TokenBreakdown's total.
|
||||
const total =
|
||||
(entry.inputTokens ?? 0) +
|
||||
(entry.outputTokens ?? 0) +
|
||||
(entry.cachedInputTokens ?? 0) +
|
||||
(entry.cacheCreationTokens ?? 0);
|
||||
const total = (entry.inputTokens ?? 0) + (entry.outputTokens ?? 0);
|
||||
const isError = entry.decision === "deny" || entry.status >= 400;
|
||||
return (
|
||||
<div key={entry.id}>
|
||||
@@ -1434,30 +1277,20 @@ function SessionEntriesRow({ session }: { session: AIAccessLogSession }) {
|
||||
>
|
||||
{entry.model || "—"}
|
||||
</code>
|
||||
<FullTooltip
|
||||
disabled={total === 0}
|
||||
content={<TokenBreakdown entry={entry} />}
|
||||
<span
|
||||
className={
|
||||
"text-xs text-nb-gray-400 font-mono whitespace-nowrap tabular-nums w-[110px] text-right shrink-0"
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"text-xs text-nb-gray-400 font-mono whitespace-nowrap tabular-nums w-[110px] text-right shrink-0"
|
||||
}
|
||||
>
|
||||
{total.toLocaleString()} tokens
|
||||
</span>
|
||||
</FullTooltip>
|
||||
<FullTooltip
|
||||
disabled={!hasCostBreakdown(entry)}
|
||||
content={<CostBreakdown {...entry} />}
|
||||
{total.toLocaleString()} tokens
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"text-xs text-nb-gray-400 font-mono whitespace-nowrap tabular-nums w-[64px] text-right shrink-0"
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"text-xs text-nb-gray-400 font-mono whitespace-nowrap tabular-nums w-[64px] text-right shrink-0"
|
||||
}
|
||||
>
|
||||
${entry.costUsd.toFixed(4)}
|
||||
</span>
|
||||
</FullTooltip>
|
||||
${entry.costUsd.toFixed(4)}
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className={"border-t border-nb-gray-800/70"}>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import ButtonGroup from "@components/ButtonGroup";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
import { DataTable } from "@components/table/DataTable";
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
Tooltip,
|
||||
} from "chart.js";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import dayjs from "dayjs";
|
||||
import { ActivityIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -44,18 +42,6 @@ type DayBucket = {
|
||||
input: number;
|
||||
output: number;
|
||||
cost: number;
|
||||
// Prompt-cache accounting: read/write token buckets and the cache share of cost.
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
cacheCost: number;
|
||||
// Per-bucket cost split, so the Cost hover can break the day down the same
|
||||
// way the access log breaks down a request. Undefined on older management
|
||||
// servers that send only the two cost aggregates — undefined and zero mean
|
||||
// different things here, so these stay optional.
|
||||
inputCost?: number;
|
||||
outputCost?: number;
|
||||
cacheReadCost?: number;
|
||||
cacheWriteCost?: number;
|
||||
};
|
||||
|
||||
// AgentOverviewPanel shows account consumption over time as a per-day bar
|
||||
@@ -220,43 +206,13 @@ function DailyBreakdownTable({ daily }: { daily: DayBucket[] }) {
|
||||
},
|
||||
{
|
||||
id: "total",
|
||||
accessorFn: (row) =>
|
||||
row.input + row.output + row.cacheRead + row.cacheWrite,
|
||||
accessorFn: (row) => row.input + row.output,
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Total Tokens</DataTableHeader>
|
||||
),
|
||||
// Total includes the additive prompt-cache buckets; hover breaks them out.
|
||||
cell: ({ row }) => {
|
||||
const d = row.original;
|
||||
const total = d.input + d.output + d.cacheRead + d.cacheWrite;
|
||||
return (
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"text-xs flex flex-col gap-1"}>
|
||||
<BreakdownRow
|
||||
value={d.input.toLocaleString()}
|
||||
label={"input"}
|
||||
/>
|
||||
<BreakdownRow
|
||||
value={d.output.toLocaleString()}
|
||||
label={"output"}
|
||||
/>
|
||||
<BreakdownRow
|
||||
value={d.cacheRead.toLocaleString()}
|
||||
label={"cache read"}
|
||||
/>
|
||||
<BreakdownRow
|
||||
value={d.cacheWrite.toLocaleString()}
|
||||
label={"cache write"}
|
||||
/>
|
||||
<BreakdownTotal value={total.toLocaleString()} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<NumberCell value={total} />
|
||||
</FullTooltip>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<NumberCell value={row.original.input + row.original.output} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "cost",
|
||||
@@ -264,74 +220,13 @@ function DailyBreakdownTable({ daily }: { daily: DayBucket[] }) {
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Cost</DataTableHeader>
|
||||
),
|
||||
// Mirrors the access log's Cost hover: one row per bucket the provider
|
||||
// bills separately when the server sends the split, otherwise the coarse
|
||||
// cache-vs-rest split derivable from the two aggregates. The shapes are
|
||||
// told apart by whether inputCost is defined, not by whether it is zero.
|
||||
cell: ({ row }) => {
|
||||
const d = row.original;
|
||||
const hasBreakdown =
|
||||
d.inputCost !== undefined || d.outputCost !== undefined;
|
||||
const display = (
|
||||
<span
|
||||
className={
|
||||
"text-nb-gray-300 px-3 py-2 font-mono whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
${d.cost.toFixed(2)}
|
||||
</span>
|
||||
);
|
||||
// Nothing to break out: no cache spend and no per-bucket split.
|
||||
if (d.cacheCost <= 0 && !hasBreakdown) return display;
|
||||
return (
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"text-xs flex flex-col gap-1 font-mono"}>
|
||||
{hasBreakdown ? (
|
||||
<>
|
||||
<BreakdownRow
|
||||
mono={true}
|
||||
value={usd(d.inputCost ?? 0)}
|
||||
label={"input"}
|
||||
/>
|
||||
<BreakdownRow
|
||||
mono={true}
|
||||
value={usd(d.outputCost ?? 0)}
|
||||
label={"output"}
|
||||
/>
|
||||
<BreakdownRow
|
||||
mono={true}
|
||||
value={usd(d.cacheReadCost ?? 0)}
|
||||
label={"cache read"}
|
||||
/>
|
||||
<BreakdownRow
|
||||
mono={true}
|
||||
value={usd(d.cacheWriteCost ?? 0)}
|
||||
label={"cache write"}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BreakdownRow
|
||||
mono={true}
|
||||
value={usd(d.cost - d.cacheCost)}
|
||||
label={"input + output"}
|
||||
/>
|
||||
<BreakdownRow
|
||||
mono={true}
|
||||
value={usd(d.cacheCost)}
|
||||
label={"cache"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<BreakdownTotal mono={true} value={usd(d.cost)} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{display}
|
||||
</FullTooltip>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={"text-nb-gray-300 px-3 py-2 font-mono whitespace-nowrap"}
|
||||
>
|
||||
${row.original.cost.toFixed(2)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
@@ -385,59 +280,6 @@ function NumberCell({ value }: { value: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
// The two hover breakdowns below follow the access-log table's Tokens and Cost
|
||||
// hovers: one "<value> <label>" row per bucket, then the day's aggregate on a
|
||||
// separated total row. Buckets are listed even when zero — a zero cache-read
|
||||
// line says the day never hit the cache, and a fixed set of rows keeps the
|
||||
// hover comparable between days.
|
||||
|
||||
// usd renders a breakdown amount at the precision the router meters at. The
|
||||
// Cost column rounds a whole day to cents, but the hover is there for the
|
||||
// detail — prompt-cache reads in particular are routinely sub-cent.
|
||||
function usd(amount: number): string {
|
||||
return `$${amount.toFixed(4)}`;
|
||||
}
|
||||
|
||||
// mono keeps cost figures aligned digit-for-digit; labels stay in the body face
|
||||
// so they don't read as data.
|
||||
function BreakdownRow({
|
||||
value,
|
||||
label,
|
||||
mono = false,
|
||||
}: {
|
||||
value: string;
|
||||
label: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={"flex items-center gap-2 whitespace-nowrap"}>
|
||||
<span className={"font-medium"}>{value}</span>
|
||||
<span className={cn("text-nb-gray-400", mono && "font-sans")}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BreakdownTotal({
|
||||
value,
|
||||
mono = false,
|
||||
}: {
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"border-t border-nb-gray-800 mt-0.5 pt-1 flex items-center gap-2 text-nb-gray-400 whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
<span className={"font-medium text-nb-gray-200"}>{value}</span>
|
||||
<span className={cn(mono && "font-sans")}>total</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConsumptionByDayChart({
|
||||
daily,
|
||||
metric,
|
||||
@@ -498,25 +340,6 @@ function ConsumptionByDayChart({
|
||||
: `${ctx.dataset.label}: ${Number(
|
||||
ctx.parsed.y,
|
||||
).toLocaleString()}`,
|
||||
// Surface the day's prompt-cache share on hover without adding chart series.
|
||||
// Gate on the figure this metric actually shows: a day with cache
|
||||
// tokens but no cache spend (unpriced model) would otherwise
|
||||
// render "Cache: $0.00", implying a priced-but-free day.
|
||||
afterBody: (items) => {
|
||||
const d = daily[items[0]?.dataIndex ?? -1];
|
||||
if (!d) return [];
|
||||
if (metric === "cost") {
|
||||
return d.cacheCost > 0
|
||||
? [`Cache: $${d.cacheCost.toFixed(2)}`]
|
||||
: [];
|
||||
}
|
||||
return d.cacheRead + d.cacheWrite > 0
|
||||
? [
|
||||
`Cache read: ${d.cacheRead.toLocaleString()}`,
|
||||
`Cache write: ${d.cacheWrite.toLocaleString()}`,
|
||||
]
|
||||
: [];
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -571,13 +394,6 @@ function toDailyBuckets(buckets: APIAgentNetworkUsageBucket[]): DayBucket[] {
|
||||
input: b.input_tokens ?? 0,
|
||||
output: b.output_tokens ?? 0,
|
||||
cost: b.cost_usd ?? 0,
|
||||
cacheRead: b.cached_input_tokens ?? 0,
|
||||
cacheWrite: b.cache_creation_tokens ?? 0,
|
||||
cacheCost: b.cache_cost_usd ?? 0,
|
||||
inputCost: b.input_cost_usd,
|
||||
outputCost: b.output_cost_usd,
|
||||
cacheReadCost: b.cached_input_cost_usd,
|
||||
cacheWriteCost: b.cache_creation_cost_usd,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -590,17 +406,7 @@ function toDailyBuckets(buckets: APIAgentNetworkUsageBucket[]): DayBucket[] {
|
||||
// Cap at a year of days as a defensive bound against a bad range.
|
||||
for (let i = 0; i < 366 && !cur.isAfter(end); i++) {
|
||||
const key = cur.format("YYYY-MM-DD");
|
||||
out.push(
|
||||
map.get(key) ?? {
|
||||
key,
|
||||
input: 0,
|
||||
output: 0,
|
||||
cost: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cacheCost: 0,
|
||||
},
|
||||
);
|
||||
out.push(map.get(key) ?? { key, input: 0, output: 0, cost: 0 });
|
||||
cur = cur.add(1, "day");
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -24,17 +24,6 @@ export type APIAgentNetworkAccessLog = {
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_usd: number;
|
||||
// Prompt-cache accounting. Optional — absent on older management servers.
|
||||
cached_input_tokens?: number;
|
||||
cache_creation_tokens?: number;
|
||||
cache_cost_usd?: number;
|
||||
// Per-bucket cost breakdown — the base of the cost fields; cost_usd is their
|
||||
// sum and cache_cost_usd the cache pair. Optional — absent on older
|
||||
// management servers, where only the two aggregates are available.
|
||||
input_cost_usd?: number;
|
||||
cached_input_cost_usd?: number;
|
||||
cache_creation_cost_usd?: number;
|
||||
output_cost_usd?: number;
|
||||
user_id?: string;
|
||||
source_ip?: string;
|
||||
method?: string;
|
||||
@@ -74,16 +63,6 @@ export type APIAgentNetworkAccessLogSession = {
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_usd: number;
|
||||
cached_input_tokens?: number;
|
||||
cache_creation_tokens?: number;
|
||||
cache_cost_usd?: number;
|
||||
// Per-bucket cost breakdown — the base of the cost fields; cost_usd is their
|
||||
// sum and cache_cost_usd the cache pair. Optional — absent on older
|
||||
// management servers, where only the two aggregates are available.
|
||||
input_cost_usd?: number;
|
||||
cached_input_cost_usd?: number;
|
||||
cache_creation_cost_usd?: number;
|
||||
output_cost_usd?: number;
|
||||
providers?: string[];
|
||||
models?: string[];
|
||||
decision: string;
|
||||
@@ -98,16 +77,6 @@ export type APIAgentNetworkUsageBucket = {
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_usd: number;
|
||||
cached_input_tokens?: number;
|
||||
cache_creation_tokens?: number;
|
||||
cache_cost_usd?: number;
|
||||
// Per-bucket cost breakdown — the base of the cost fields; cost_usd is their
|
||||
// sum and cache_cost_usd the cache pair. Optional — absent on older
|
||||
// management servers, where only the two aggregates are available.
|
||||
input_cost_usd?: number;
|
||||
cached_input_cost_usd?: number;
|
||||
cache_creation_cost_usd?: number;
|
||||
output_cost_usd?: number;
|
||||
};
|
||||
|
||||
export type UsageGranularity = "day" | "week" | "month";
|
||||
@@ -216,14 +185,7 @@ export function accessLogFromAgentAPI(
|
||||
model: entry.model ?? "",
|
||||
inputTokens: entry.input_tokens ?? 0,
|
||||
outputTokens: entry.output_tokens ?? 0,
|
||||
cachedInputTokens: entry.cached_input_tokens ?? 0,
|
||||
cacheCreationTokens: entry.cache_creation_tokens ?? 0,
|
||||
costUsd: entry.cost_usd ?? 0,
|
||||
cacheCostUsd: entry.cache_cost_usd ?? 0,
|
||||
inputCostUsd: entry.input_cost_usd,
|
||||
cachedInputCostUsd: entry.cached_input_cost_usd,
|
||||
cacheCreationCostUsd: entry.cache_creation_cost_usd,
|
||||
outputCostUsd: entry.output_cost_usd,
|
||||
durationMs: entry.duration_ms,
|
||||
decision,
|
||||
denyReason: decision === "deny" ? entry.deny_reason : undefined,
|
||||
@@ -266,14 +228,7 @@ export function accessLogSessionFromAgentAPI(
|
||||
inputTokens: session.input_tokens ?? 0,
|
||||
outputTokens: session.output_tokens ?? 0,
|
||||
totalTokens: session.total_tokens ?? 0,
|
||||
cachedInputTokens: session.cached_input_tokens ?? 0,
|
||||
cacheCreationTokens: session.cache_creation_tokens ?? 0,
|
||||
costUsd: session.cost_usd ?? 0,
|
||||
cacheCostUsd: session.cache_cost_usd ?? 0,
|
||||
inputCostUsd: session.input_cost_usd,
|
||||
cachedInputCostUsd: session.cached_input_cost_usd,
|
||||
cacheCreationCostUsd: session.cache_creation_cost_usd,
|
||||
outputCostUsd: session.output_cost_usd,
|
||||
providers: session.providers ?? [],
|
||||
models: session.models ?? [],
|
||||
decision,
|
||||
|
||||
@@ -43,17 +43,6 @@ export type ProviderModel = {
|
||||
id: string;
|
||||
inputPer1k: number;
|
||||
outputPer1k: number;
|
||||
// Optional prompt-cache rates (USD per 1k tokens). undefined means
|
||||
// "inherit NetBird's default rate for this model" — the backend folds
|
||||
// the default in at synthesis time; an explicit 0 means "no discount,
|
||||
// bill this cache bucket at the input rate". Keep undefined distinct
|
||||
// from 0 when round-tripping.
|
||||
// OpenAI shape: cached prompt tokens (a subset of input tokens).
|
||||
cachedInputPer1k?: number;
|
||||
// Anthropic shape: the two additive cache buckets (read ≈0.1x input,
|
||||
// creation ≈1.25x input).
|
||||
cacheReadPer1k?: number;
|
||||
cacheCreationPer1k?: number;
|
||||
};
|
||||
|
||||
export type AIProvider = {
|
||||
@@ -186,17 +175,7 @@ export type AIAccessLogEntry = {
|
||||
model: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
// Prompt-cache buckets: read/write token counts and the cache share of costUsd. Optional — absent on older management servers.
|
||||
cachedInputTokens?: number;
|
||||
cacheCreationTokens?: number;
|
||||
costUsd: number;
|
||||
cacheCostUsd?: number;
|
||||
// Per-bucket cost breakdown. Undefined (not 0) when the server predates the
|
||||
// breakdown, so the UI can tell "no split available" from "split is zero".
|
||||
inputCostUsd?: number;
|
||||
cachedInputCostUsd?: number;
|
||||
cacheCreationCostUsd?: number;
|
||||
outputCostUsd?: number;
|
||||
durationMs: number;
|
||||
decision: AIAccessLogDecision;
|
||||
denyReason?: string;
|
||||
@@ -234,16 +213,7 @@ export type AIAccessLogSession = {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
cachedInputTokens?: number;
|
||||
cacheCreationTokens?: number;
|
||||
costUsd: number;
|
||||
cacheCostUsd?: number;
|
||||
// Per-bucket cost breakdown. Undefined (not 0) when the server predates the
|
||||
// breakdown, so the UI can tell "no split available" from "split is zero".
|
||||
inputCostUsd?: number;
|
||||
cachedInputCostUsd?: number;
|
||||
cacheCreationCostUsd?: number;
|
||||
outputCostUsd?: number;
|
||||
providers: string[]; // distinct vendor ids seen in the session
|
||||
models: string[]; // distinct models seen in the session
|
||||
decision: AIAccessLogDecision;
|
||||
|
||||
@@ -8,12 +8,6 @@ export type CatalogModel = {
|
||||
label: string;
|
||||
input_per_1k: number;
|
||||
output_per_1k: number;
|
||||
// Default cache rates, present only when the model has one. Used to
|
||||
// prefill the model row so the operator sees (and can override) the
|
||||
// rate NetBird would bill with.
|
||||
cached_input_per_1k?: number;
|
||||
cache_read_per_1k?: number;
|
||||
cache_creation_per_1k?: number;
|
||||
context_window: number;
|
||||
};
|
||||
|
||||
@@ -48,13 +42,6 @@ export type CatalogProvider = {
|
||||
// can choose between the always-on x-bf-lh- log family and the
|
||||
// declared x-bf-dim- telemetry family.
|
||||
identity_injection?: CatalogIdentityInjection;
|
||||
// pricing_surfaces names the cost-meter surfaces this provider's
|
||||
// traffic is metered under ("openai", "anthropic", "bedrock"). The
|
||||
// modal uses it to decide which cache-rate fields apply: "openai" →
|
||||
// cached input (subset discount); "anthropic"/"bedrock" → cache read
|
||||
// + cache write (additive buckets). Absent for gateway/custom entries
|
||||
// — show every cache field for those.
|
||||
pricing_surfaces?: string[];
|
||||
models: CatalogModel[];
|
||||
};
|
||||
|
||||
|
||||
@@ -120,10 +120,7 @@ export const OnboardingProvider = ({
|
||||
// deciding, so a slow mode fetch can't briefly show the regular form to an
|
||||
// account that turns out to be Agent Network-only via config.
|
||||
if (agentNetworkModeLoading) return false;
|
||||
// The regular flow shows on both cloud and self-hosted, but the signup
|
||||
// survey relies on a JWT domain claim self-hosted IdPs don't emit, so it
|
||||
// only counts toward showing (and is only rendered) on cloud — self-hosted
|
||||
// starts directly at the intent step.
|
||||
if (!isNetBirdCloud()) return false;
|
||||
const isSignupFormPending = isNetBirdCloud()
|
||||
? !!account?.onboarding?.signup_form_pending
|
||||
: false;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { Label } from "@components/Label";
|
||||
import HelpText from "@components/HelpText";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { ToggleSwitch } from "@components/ToggleSwitch";
|
||||
import Button from "@components/Button";
|
||||
import { Input } from "@components/Input";
|
||||
import cidr from "ip-cidr";
|
||||
import { hostSuffixFor, isIPv6 } from "@utils/ip";
|
||||
import {
|
||||
FlagIcon,
|
||||
InfoIcon,
|
||||
MinusCircleIcon,
|
||||
NetworkIcon,
|
||||
PlusIcon,
|
||||
@@ -19,7 +22,11 @@ import {
|
||||
SelectOption,
|
||||
} from "@components/select/SelectDropdown";
|
||||
import { CountrySelector } from "@/components/ui/CountrySelector";
|
||||
import { AccessRestrictions, CrowdSecMode } from "@/interfaces/ReverseProxy";
|
||||
import {
|
||||
AccessRestrictions,
|
||||
AllowMatch,
|
||||
CrowdSecMode,
|
||||
} from "@/interfaces/ReverseProxy";
|
||||
import { ReverseProxyCrowdSecIPReputation } from "@/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation";
|
||||
|
||||
type AccessAction = "allow" | "block";
|
||||
@@ -118,9 +125,33 @@ function restrictionsToRules(
|
||||
return rules;
|
||||
}
|
||||
|
||||
// allowSpansCategories reports whether the allow rules cover both a location
|
||||
// (country) and an IP/CIDR category. The all-vs-any combine mode only changes
|
||||
// the outcome in that case, so allow_match is only persisted then.
|
||||
//
|
||||
// requireValue distinguishes the two callers: persistence needs a real value
|
||||
// (an empty rule contributes no allowlist), while the toggle's visibility keys
|
||||
// off the selected type alone so it appears as soon as a category is chosen,
|
||||
// before the user finishes typing.
|
||||
function allowSpansCategories(
|
||||
rules: AccessRule[],
|
||||
requireValue: boolean,
|
||||
): boolean {
|
||||
let hasCountry = false;
|
||||
let hasCidr = false;
|
||||
for (const rule of rules) {
|
||||
if (rule.action !== "allow") continue;
|
||||
if (requireValue && !rule.value) continue;
|
||||
if (rule.type === "country") hasCountry = true;
|
||||
else hasCidr = true;
|
||||
}
|
||||
return hasCountry && hasCidr;
|
||||
}
|
||||
|
||||
function rulesToRestrictions(
|
||||
rules: AccessRule[],
|
||||
crowdsecMode?: CrowdSecMode,
|
||||
allowMatch?: AllowMatch,
|
||||
): AccessRestrictions | undefined {
|
||||
const allowed_countries: string[] = [];
|
||||
const blocked_countries: string[] = [];
|
||||
@@ -153,12 +184,19 @@ function rulesToRestrictions(
|
||||
|
||||
if (!hasAny) return undefined;
|
||||
|
||||
// Only emit allow_match when "any" is selected and the allow rules actually
|
||||
// span both categories; otherwise the mode is a no-op and the backend
|
||||
// defaults to "all".
|
||||
const emitAllowMatch =
|
||||
allowMatch === AllowMatch.ANY && allowSpansCategories(rules, true);
|
||||
|
||||
return {
|
||||
...(allowed_countries.length > 0 && { allowed_countries }),
|
||||
...(blocked_countries.length > 0 && { blocked_countries }),
|
||||
...(allowed_cidrs.length > 0 && { allowed_cidrs }),
|
||||
...(blocked_cidrs.length > 0 && { blocked_cidrs }),
|
||||
...(hasCrowdSec && { crowdsec_mode: crowdsecMode }),
|
||||
...(emitAllowMatch && { allow_match: AllowMatch.ANY }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -167,6 +205,10 @@ type Props = {
|
||||
onChange: (value: AccessRestrictions | undefined) => void;
|
||||
onValidationChange?: (hasErrors: boolean) => void;
|
||||
supportsCrowdSec?: boolean;
|
||||
// isNewService selects the default allow-combine mode: new services default
|
||||
// to "any" (OR across categories), while existing services without a stored
|
||||
// allow_match keep "all" (AND) for backward compatibility.
|
||||
isNewService?: boolean;
|
||||
};
|
||||
|
||||
function validateRule(rule: AccessRule): string {
|
||||
@@ -193,6 +235,7 @@ export const ReverseProxyAccessControlRules = ({
|
||||
onChange,
|
||||
onValidationChange,
|
||||
supportsCrowdSec,
|
||||
isNewService,
|
||||
}: Props) => {
|
||||
const [rules, dispatch] = useReducer(
|
||||
rulesReducer,
|
||||
@@ -204,6 +247,15 @@ export const ReverseProxyAccessControlRules = ({
|
||||
value?.crowdsec_mode ?? CrowdSecMode.OFF,
|
||||
);
|
||||
|
||||
const [allowMatch, setAllowMatch] = useState<AllowMatch>(
|
||||
value?.allow_match ?? (isNewService ? AllowMatch.ANY : AllowMatch.ALL),
|
||||
);
|
||||
|
||||
const showAllowMatch = useMemo(
|
||||
() => allowSpansCategories(rules, false),
|
||||
[rules],
|
||||
);
|
||||
|
||||
const errors = useMemo(
|
||||
() => Object.fromEntries(rules.map((r) => [r.id, validateRule(r)])),
|
||||
[rules],
|
||||
@@ -227,8 +279,8 @@ export const ReverseProxyAccessControlRules = ({
|
||||
}, [supportsCrowdSec]);
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current(rulesToRestrictions(rules, crowdsecMode));
|
||||
}, [rules, crowdsecMode]);
|
||||
onChangeRef.current(rulesToRestrictions(rules, crowdsecMode, allowMatch));
|
||||
}, [rules, crowdsecMode, allowMatch]);
|
||||
|
||||
useEffect(() => {
|
||||
onValidationChangeRef.current?.(hasErrors);
|
||||
@@ -242,6 +294,42 @@ export const ReverseProxyAccessControlRules = ({
|
||||
onChange={setCrowdsecMode}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`flex items-center justify-between gap-4 mb-4 ${
|
||||
showAllowMatch ? "" : "opacity-60"
|
||||
}`}
|
||||
data-testid="allow-match"
|
||||
>
|
||||
<div>
|
||||
<Label className="flex items-center gap-2">
|
||||
Require all allow rules
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className="text-xs max-w-xs">
|
||||
When on, a connection must match every allow category (an
|
||||
allowed country and an allowed IP/CIDR). When off, matching
|
||||
any one is enough. Block rules always take priority.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<InfoIcon size={14} className="text-nb-gray-500" />
|
||||
</FullTooltip>
|
||||
</Label>
|
||||
<HelpText margin={false}>
|
||||
{showAllowMatch
|
||||
? "Off: match any allow rule. On: match all of them."
|
||||
: "Applies once you have both a country and an IP/CIDR allow rule."}
|
||||
</HelpText>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={allowMatch === AllowMatch.ALL}
|
||||
onCheckedChange={(checked) =>
|
||||
setAllowMatch(checked ? AllowMatch.ALL : AllowMatch.ANY)
|
||||
}
|
||||
disabled={!showAllowMatch}
|
||||
data-testid="allow-match-toggle"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Access Control Rules</Label>
|
||||
<HelpText>
|
||||
|
||||
@@ -814,6 +814,7 @@ export default function ReverseProxyModal({
|
||||
onChange={setAccessRestrictions}
|
||||
onValidationChange={setAccessControlHasErrors}
|
||||
supportsCrowdSec={selectedDomain?.supports_crowdsec}
|
||||
isNewService={!reverseProxy?.id}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -4,14 +4,13 @@ import {
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@components/Accordion";
|
||||
import { Callout } from "@components/Callout";
|
||||
import Code from "@components/Code";
|
||||
import { SelectDropdown } from "@components/select/SelectDropdown";
|
||||
import Separator from "@components/Separator";
|
||||
import Steps from "@components/Steps";
|
||||
import TabsContentPadding, { TabsContent } from "@components/Tabs";
|
||||
import { PackageIcon, TerminalSquareIcon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { IconBrandUbuntu } from "@tabler/icons-react";
|
||||
import { TerminalSquareIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import {
|
||||
NetBirdUpCommand,
|
||||
@@ -26,99 +25,6 @@ type Props = {
|
||||
hostname?: string;
|
||||
};
|
||||
|
||||
type Distro = {
|
||||
label: string;
|
||||
value: string;
|
||||
/** Commands that register the NetBird package repository. */
|
||||
repository: string[];
|
||||
/** Commands that run before the install lines, after the repository is added. */
|
||||
beforeInstall?: string[];
|
||||
cli: string;
|
||||
/**
|
||||
* Desktop app install lines. Omitted when the distribution cannot run it,
|
||||
* in which case only the CLI is offered.
|
||||
*/
|
||||
desktopApp?: string[];
|
||||
note: string;
|
||||
};
|
||||
|
||||
const YUM_REPOSITORY = [
|
||||
"sudo tee /etc/yum.repos.d/netbird.repo <<EOF",
|
||||
"[netbird]",
|
||||
"name=netbird",
|
||||
"baseurl=https://pkgs.netbird.io/yum/",
|
||||
"enabled=1",
|
||||
"gpgcheck=1",
|
||||
"gpgkey=https://pkgs.netbird.io/yum/repodata/repomd.xml.key",
|
||||
"repo_gpgcheck=1",
|
||||
"EOF",
|
||||
];
|
||||
|
||||
// The desktop app links GTK 4.10+ and WebKitGTK 6.0, and the released
|
||||
// packages do not declare those as dependencies, so each install line names
|
||||
// them explicitly. Distributions below that floor get the CLI only.
|
||||
const DISTROS: Distro[] = [
|
||||
{
|
||||
label: "Debian / Ubuntu (APT)",
|
||||
value: "apt",
|
||||
repository: [
|
||||
"sudo apt-get update",
|
||||
"sudo apt-get install ca-certificates curl gnupg -y",
|
||||
"curl -sSL https://pkgs.netbird.io/debian/public.key | sudo gpg --dearmor --output /usr/share/keyrings/netbird-archive-keyring.gpg",
|
||||
`echo 'deb [signed-by=/usr/share/keyrings/netbird-archive-keyring.gpg] https://pkgs.netbird.io/debian stable main' | sudo tee /etc/apt/sources.list.d/netbird.list`,
|
||||
],
|
||||
beforeInstall: ["sudo apt-get update"],
|
||||
cli: "sudo apt-get install netbird",
|
||||
desktopApp: [
|
||||
"sudo apt-get install netbird-ui libgtk-4-1 libwebkitgtk-6.0-4 xdg-utils",
|
||||
],
|
||||
note: "The desktop app needs Ubuntu 24.04 or Debian 13 and newer. On earlier releases install the CLI only.",
|
||||
},
|
||||
{
|
||||
label: "Fedora (DNF)",
|
||||
value: "fedora",
|
||||
repository: YUM_REPOSITORY,
|
||||
cli: "sudo dnf install netbird",
|
||||
desktopApp: ["sudo dnf install netbird-ui gtk4 webkitgtk6.0 xdg-utils"],
|
||||
note: "The desktop app needs Fedora 43 and newer. On earlier releases install the CLI only.",
|
||||
},
|
||||
{
|
||||
label: "RHEL / AlmaLinux / Rocky (DNF)",
|
||||
value: "rhel",
|
||||
repository: YUM_REPOSITORY,
|
||||
cli: "sudo dnf install netbird",
|
||||
// WebKitGTK 6.0 ships in EPEL rather than the base repositories, so the
|
||||
// desktop app path enables it first. CLI-only users do not need it.
|
||||
desktopApp: [
|
||||
"sudo dnf install epel-release -y",
|
||||
"sudo dnf install netbird-ui gtk4 webkitgtk6.0 xdg-utils",
|
||||
],
|
||||
note: "The desktop app needs version 10 or newer with EPEL enabled, which provides WebKitGTK 6.0. On version 9 install the CLI only.",
|
||||
},
|
||||
{
|
||||
label: "openSUSE (Zypper)",
|
||||
value: "opensuse",
|
||||
// Zypper does not read gpgkey= from a .repo file, so the repository key is
|
||||
// imported by an explicit refresh instead of the YUM_REPOSITORY snippet.
|
||||
repository: [
|
||||
"sudo zypper --non-interactive addrepo -f -g https://pkgs.netbird.io/yum/ netbird",
|
||||
"sudo zypper --gpg-auto-import-keys refresh netbird",
|
||||
],
|
||||
cli: "sudo zypper install netbird",
|
||||
desktopApp: [
|
||||
"sudo zypper install netbird-ui libgtk-4-1 libwebkitgtk-6_0-4 xdg-utils",
|
||||
],
|
||||
note: "The desktop app needs Tumbleweed or Leap 15.6 and newer. On earlier releases install the CLI only.",
|
||||
},
|
||||
{
|
||||
label: "Amazon Linux (YUM)",
|
||||
value: "amazon",
|
||||
repository: YUM_REPOSITORY,
|
||||
cli: "sudo yum install netbird",
|
||||
note: "Amazon Linux does not ship GTK 4 or WebKitGTK 6.0, so only the CLI is available.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function LinuxTab({
|
||||
setupKey,
|
||||
setupKeyContent,
|
||||
@@ -126,20 +32,8 @@ export default function LinuxTab({
|
||||
showSetupKeyInfo = false,
|
||||
hostname,
|
||||
}: Readonly<Props>) {
|
||||
const [distroValue, setDistroValue] = useState(DISTROS[0].value);
|
||||
const distro =
|
||||
DISTROS.find((option) => option.value === distroValue) ?? DISTROS[0];
|
||||
|
||||
const runStep = setupKeyContent ? 3 : 2;
|
||||
const usingSetupKey = !!setupKey || !!setupKeyPlaceholder;
|
||||
|
||||
const hasDesktopApp = !!distro.desktopApp?.length;
|
||||
const installLines = [
|
||||
...(distro.beforeInstall ?? []),
|
||||
distro.cli,
|
||||
...(distro.desktopApp ?? []),
|
||||
];
|
||||
|
||||
return (
|
||||
<TabsContent value={String(OperatingSystem.LINUX)}>
|
||||
<TabsContentPadding>
|
||||
@@ -172,56 +66,43 @@ export default function LinuxTab({
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>
|
||||
<PackageIcon size={16} />
|
||||
Install manually with a package manager
|
||||
<IconBrandUbuntu size={16} />
|
||||
Install manually on Ubuntu
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className={"mt-1"}>
|
||||
<SelectDropdown
|
||||
value={distroValue}
|
||||
className={"w-[280px]"}
|
||||
onChange={setDistroValue}
|
||||
placeholder={"Select distribution"}
|
||||
options={DISTROS.map(({ label, value }) => ({
|
||||
label,
|
||||
value,
|
||||
}))}
|
||||
data-testid={"linux-distro-select"}
|
||||
/>
|
||||
</div>
|
||||
<Steps>
|
||||
<Steps.Step step={1}>
|
||||
<p>Add our repository</p>
|
||||
<Code codeToCopy={distro.repository.join("\n")}>
|
||||
{distro.repository.map((line) => (
|
||||
<Code.Line key={line}>{line}</Code.Line>
|
||||
))}
|
||||
<Code>
|
||||
<Code.Line>sudo apt-get update</Code.Line>
|
||||
<Code.Line>
|
||||
sudo apt install ca-certificates curl gnupg -y
|
||||
</Code.Line>
|
||||
<Code.Line>
|
||||
curl -sSL https://pkgs.netbird.io/debian/public.key | sudo
|
||||
gpg --dearmor --output
|
||||
/usr/share/keyrings/netbird-archive-keyring.gpg
|
||||
</Code.Line>
|
||||
<Code.Line>
|
||||
{`echo 'deb [signed-by=/usr/share/keyrings/netbird-archive-keyring.gpg] https://pkgs.netbird.io/debian stable main' | sudo tee /etc/apt/sources.list.d/netbird.list`}
|
||||
</Code.Line>
|
||||
</Code>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={2}>
|
||||
<p>Install NetBird</p>
|
||||
<Code codeToCopy={installLines.join("\n")}>
|
||||
{distro.beforeInstall?.map((line) => (
|
||||
<Code.Line key={line}>{line}</Code.Line>
|
||||
))}
|
||||
{hasDesktopApp && (
|
||||
<Code.Comment># for CLI only</Code.Comment>
|
||||
)}
|
||||
<Code.Line>{distro.cli}</Code.Line>
|
||||
{hasDesktopApp && (
|
||||
<>
|
||||
<Code.Comment># for the desktop app</Code.Comment>
|
||||
{distro.desktopApp?.map((line) => (
|
||||
<Code.Line key={line}>{line}</Code.Line>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<Code
|
||||
codeToCopy={[
|
||||
`sudo apt-get update`,
|
||||
`sudo apt-get install netbird`,
|
||||
`sudo apt-get install netbird-ui`,
|
||||
].join("\n")}
|
||||
>
|
||||
<Code.Line>sudo apt-get update</Code.Line>
|
||||
<Code.Comment># for CLI only</Code.Comment>
|
||||
<Code.Line>sudo apt-get install netbird</Code.Line>
|
||||
<Code.Comment># for GUI package</Code.Comment>
|
||||
<Code.Line>sudo apt-get install netbird-ui</Code.Line>
|
||||
</Code>
|
||||
<Callout variant={"info"} className={"mt-1"}>
|
||||
{distro.note}
|
||||
{hasDesktopApp &&
|
||||
" Desktop app packages are available for x86_64 only."}
|
||||
</Callout>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={3} line={false}>
|
||||
<p>
|
||||
|
||||
Reference in new issue
Block a user