Compare commits

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

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

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

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

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

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

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

No files matched your search

@@ -0,0 +1,144 @@
/**
* Agent Network settings wire-shape spec.
*
* The management API signals "account not bootstrapped" differently by
* generation: current servers answer GET /agent-network/settings with the
* defaults object carrying an empty cluster/subdomain/endpoint, older ones
* with 200 + a JSON null body, and the oldest with a 404.
* useAgentNetworkSettings normalizes all three to the same null-settings
* signal, and this spec pins that: the providers page must render the
* connect-first endpoint placeholder for every unbootstrapped shape, and the
* endpoint badge once the account is bootstrapped. The settings route is
* mocked per test, so the assertions do not depend on the backend build or
* on account state left behind by other suites.
*/
import { test, expect, type Browser, type Page } from "@playwright/test";
import { loginToApp } from "../helpers/auth";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
const EMPTY_STATE_TEXT =
"Connect your first provider to set up your agent network endpoint.";
const BOOTSTRAPPED_ENDPOINT = "violet.eu.proxy.netbird.io";
// The defaults object current servers return before the account is
// bootstrapped: values present, cluster/subdomain/endpoint empty, no
// timestamps (no row has been persisted yet).
const UNBOOTSTRAPPED_DEFAULTS = {
cluster: "",
subdomain: "",
endpoint: "",
enable_log_collection: true,
enable_prompt_collection: false,
redact_pii: false,
access_log_retention_days: 30,
};
const BOOTSTRAPPED_SETTINGS = {
cluster: "eu.proxy.netbird.io",
subdomain: "violet",
endpoint: BOOTSTRAPPED_ENDPOINT,
enable_log_collection: true,
enable_prompt_collection: false,
redact_pii: false,
access_log_retention_days: 30,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
async function mockSettingsResponse(
page: Page,
response: { status: number; body: string },
) {
await page.route("**/api/agent-network/settings", (route) => {
if (route.request().method() !== "GET") return route.continue();
return route.fulfill({
status: response.status,
contentType: "application/json",
body: response.body,
});
});
}
async function openProvidersPage(
browser: Browser,
settingsResponse: { status: number; body: string },
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
// Close the context on any setup failure — the caller only receives the
// close callback once setup succeeds.
try {
await context.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch (e) {}
},
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
);
const page = await context.newPage();
await mockSettingsResponse(page, settingsResponse);
await loginToApp(page, "owner");
await page.goto("/agent-network/providers");
await page.keyboard.press("Escape");
return { page, close: () => context.close() };
} catch (e) {
await context.close();
throw e;
}
}
const UNBOOTSTRAPPED_SHAPES: {
name: string;
response: { status: number; body: string };
}[] = [
{
name: "defaults object with empty endpoint (current servers)",
response: { status: 200, body: JSON.stringify(UNBOOTSTRAPPED_DEFAULTS) },
},
{
name: "200 with JSON null body (older servers)",
response: { status: 200, body: "null" },
},
{
name: "404 (oldest servers)",
response: {
status: 404,
body: JSON.stringify({ message: "settings not found", code: 404 }),
},
},
];
test.describe("Agent Network settings wire shapes @agent-network", () => {
for (const shape of UNBOOTSTRAPPED_SHAPES) {
test(`unbootstrapped account renders the empty state: ${shape.name}`, async ({
browser,
}) => {
const { page, close } = await openProvidersPage(browser, shape.response);
try {
await expect(page.getByText(EMPTY_STATE_TEXT)).toBeVisible();
await expect(page.getByText(BOOTSTRAPPED_ENDPOINT)).toHaveCount(0);
} finally {
await close();
}
});
}
test("bootstrapped account renders the endpoint instead of the empty state", async ({
browser,
}) => {
const { page, close } = await openProvidersPage(browser, {
status: 200,
body: JSON.stringify(BOOTSTRAPPED_SETTINGS),
});
try {
await expect(page.getByText(BOOTSTRAPPED_ENDPOINT)).toBeVisible();
await expect(page.getByText(EMPTY_STATE_TEXT)).toHaveCount(0);
} finally {
await close();
}
});
});
+73 -4
View File
@@ -72,13 +72,19 @@ function mockAccounts(page: Page, state: AccountState, delayMs: number) {
async function openOnboarding(
browser: Browser,
opts: { source?: boolean; account: AccountState; delayMs?: number },
opts: {
source?: boolean;
account: AccountState;
delayMs?: number;
edition?: string;
expectOnboarding?: boolean;
},
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
await context.addInitScript(
([sourceKey, sourceValue, withSource]) => {
([sourceKey, sourceValue, withSource, edition]) => {
try {
window.localStorage.setItem("netbird-test-onboarding", "true");
if (withSource) {
@@ -87,13 +93,32 @@ async function openOnboarding(
sourceValue as string,
);
}
if (edition) {
window.localStorage.setItem("netbird-test-edition", edition as string);
}
} catch (e) {}
},
[SIGNUP_SOURCE_KEY, AGENT_NETWORK_SOURCE, !!opts.source] as const,
[
SIGNUP_SOURCE_KEY,
AGENT_NETWORK_SOURCE,
!!opts.source,
opts.edition ?? "",
] as const,
);
const page = await context.newPage();
mockAccounts(page, opts.account, opts.delayMs ?? 0);
await loginToApp(page, "owner", { expectOnboarding: true });
// The mocked GET /accounts is what drives the onboarding decision, and the
// dashboard can resolve before it lands — a "no form opened" assertion would
// then pass vacuously. Wait for the rewritten response before returning.
const accountsLoaded = page.waitForResponse(
(resp) =>
/\/api\/accounts(\?|$)/.test(resp.url()) &&
resp.request().method() === "GET",
);
await loginToApp(page, "owner", {
expectOnboarding: opts.expectOnboarding ?? true,
});
await accountsLoaded;
return { page, close: () => context.close() };
}
@@ -143,6 +168,50 @@ test.describe.serial("Onboarding form selection @onboarding", () => {
}
});
test("a self-hosted account with onboarding pending shows the regular flow at the intent step", async ({
browser,
}) => {
const { page, close } = await openOnboarding(browser, {
source: false,
edition: "oss",
account: { onboardingFlowPending: true },
});
try {
await expect(page.getByTestId(REGULAR_FORM)).toBeVisible();
await expect(page.getByTestId(AGENT_FORM)).toHaveCount(0);
// The signup survey relies on a JWT domain claim self-hosted IdPs don't
// emit, so the flow skips it and opens on the intent step. Scoped to the
// form: the dashboard behind the modal has its own "Get Started with
// NetBird" heading, and getByText matches case-insensitively.
await expect(
page
.getByTestId(REGULAR_FORM)
.getByRole("heading", { name: "Get started with NetBird" }),
).toBeVisible();
} finally {
await close();
}
});
test("a self-hosted account with only the signup form pending shows no onboarding", async ({
browser,
}) => {
const { page, close } = await openOnboarding(browser, {
source: false,
edition: "oss",
account: { signupFormPending: true },
expectOnboarding: false,
});
try {
// loginToApp resolved the dashboard, so the account state has been
// applied — neither flow should have opened.
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
await expect(page.getByTestId(AGENT_FORM)).toHaveCount(0);
} finally {
await close();
}
});
test("a slow backend never flashes the regular form for a netbird.ai signup", async ({
browser,
}) => {
+8
View File
@@ -128,6 +128,14 @@ async function openRowActions(
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 })
+19
View File
@@ -69,6 +69,25 @@ test.describe("Setup modal Linux distro instructions", () => {
);
});
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");
+8
View File
@@ -22,7 +22,15 @@ test.describe.serial("Team - Service Users @team", () => {
test("Should update role and manage access tokens", async ({ dashboardAsOwner: page }) => {
await page.locator("tr").getByText(regularUser).click();
await changeRoleTo(page, "Admin");
// Await the PUT so the role change is persisted before the next serial
// test asserts it — clicking save alone returns before the request lands.
const saveResponse = page.waitForResponse(
(resp) =>
resp.url().includes("/api/users/") && resp.request().method() === "PUT",
{ timeout: 30_000 },
);
await page.getByTestId("save-changes").click();
await saveResponse;
// Create and delete access token
const tokenName = generateRandomName("tkn_");
Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

+24 -8
View File
@@ -17,6 +17,13 @@ function formatVersion(version: string): string {
return version;
}
// Self-hosted builds can carry a numeric build suffix (e.g. "0.76.3-31256681241")
// that overflows the sidebar. Show the semver only and keep the rest for the
// tooltip. Pre-release labels like "-rc.1" are left intact so they stay visible.
function formatShortVersion(version: string): string {
return formatVersion(version).replace(/^(v?\d+(?:\.\d+)*)-\d+$/, "$1");
}
function compareVersions(current: string, latest: string): boolean {
// Returns true if latest is newer than current
if (!current || !latest) return false;
@@ -98,9 +105,15 @@ const NavigationVersionInfoContent = () => {
<div className="flex flex-col gap-1 text-nb-gray-400">
<FullTooltip
content={
<span className="text-xs">
Latest: {formatVersion(versionInfo.management_available_version)}
</span>
<div className="text-xs flex flex-col gap-1">
<span>
Installed:{" "}
{formatVersion(versionInfo.management_current_version)}
</span>
<span>
Latest: {formatVersion(versionInfo.management_available_version)}
</span>
</div>
}
side="top"
className="w-full"
@@ -108,15 +121,18 @@ const NavigationVersionInfoContent = () => {
<div className="flex items-center justify-between w-full cursor-default">
<span>Management</span>
<span className="text-nb-gray-300 font-medium">
{formatVersion(versionInfo.management_current_version)}
{formatShortVersion(versionInfo.management_current_version)}
</span>
</div>
</FullTooltip>
<FullTooltip
content={
<span className="text-xs">
Latest: {formatVersion(versionInfo.dashboard_available_version)}
</span>
<div className="text-xs flex flex-col gap-1">
<span>Installed: {formatVersion(dashboardVersion)}</span>
<span>
Latest: {formatVersion(versionInfo.dashboard_available_version)}
</span>
</div>
}
side="top"
className="w-full"
@@ -124,7 +140,7 @@ const NavigationVersionInfoContent = () => {
<div className="flex items-center justify-between w-full cursor-default">
<span>Dashboard</span>
<span className="text-nb-gray-300 font-medium">
{formatVersion(dashboardVersion)}
{formatShortVersion(dashboardVersion)}
</span>
</div>
</FullTooltip>
+2 -2
View File
@@ -57,9 +57,9 @@ export default function DataTableHeader({
{children}
{sorting &&
(column.getIsSorted() === "desc" ? (
<IconSortAscending size={16} />
) : (
<IconSortDescending size={16} />
) : (
<IconSortAscending size={16} />
))}
</div>
</FullTooltip>
File diff suppressed because it is too large. Load diff
@@ -27,6 +27,13 @@ export type APIProviderModel = {
id: string;
input_per_1k: number;
output_per_1k: number;
// Optional cache rates. Omitted on the wire = inherit NetBird's
// default rate for this model (the backend folds it in at synthesis
// time); explicit 0 = no discount (bucket bills at the input rate).
// Never coerce undefined to 0 — the two mean different things.
cached_input_per_1k?: number;
cache_read_per_1k?: number;
cache_creation_per_1k?: number;
};
export type APIProvider = {
@@ -118,8 +125,10 @@ export type APIAgentNetworkSettings = {
enable_prompt_collection: boolean;
redact_pii: boolean;
access_log_retention_days?: number;
created_at: string;
updated_at: string;
// Absent until the account is bootstrapped — pre-bootstrap the backend
// returns the defaults without a persisted row to date.
created_at?: string;
updated_at?: string;
};
// APIAgentNetworkSettingsRequest matches the PUT /agent-network/settings
@@ -156,6 +165,9 @@ function fromAPI(p: APIProvider): AIProvider {
id: m.id,
inputPer1k: m.input_per_1k,
outputPer1k: m.output_per_1k,
cachedInputPer1k: m.cached_input_per_1k,
cacheReadPer1k: m.cache_read_per_1k,
cacheCreationPer1k: m.cache_creation_per_1k,
}));
return {
id: p.id,
@@ -191,10 +203,16 @@ function fromAPI(p: APIProvider): AIProvider {
}
function toAPIModels(models: ProviderModel[]): APIProviderModel[] {
// undefined cache rates stay undefined so JSON.stringify omits the
// key: an omitted rate inherits NetBird's default, an explicit 0
// disables the discount. Coercing here would change billing.
return models.map((m) => ({
id: m.id,
input_per_1k: m.inputPer1k,
output_per_1k: m.outputPer1k,
cached_input_per_1k: m.cachedInputPer1k,
cache_read_per_1k: m.cacheReadPer1k,
cache_creation_per_1k: m.cacheCreationPer1k,
}));
}
@@ -505,10 +523,12 @@ export function useAIProviders() {
}
// useAgentNetworkSettings fetches the account-level agent-network settings.
// Returns null until the first provider is created — newer backends respond
// 200 + JSON null while no settings row exists; older backends respond 404,
// which we still tolerate via ignoreError so older deploys don't surface
// a spurious error in the empty state.
// Returns null until the account is bootstrapped (first provider create, or
// a settings update carrying a cluster). Backends signal the unbootstrapped
// state differently by age: current ones respond 200 with the defaults and
// an empty cluster/subdomain/endpoint, older ones 200 + JSON null, and the
// oldest 404 — tolerated via ignoreError so old deploys don't surface a
// spurious error in the empty state. All three normalize to null here.
export function useAgentNetworkSettings() {
const { enabled: agentNetworkEnabled } = useAgentNetworkMode();
const { data, error, isLoading, mutate } =
@@ -518,11 +538,14 @@ export function useAgentNetworkSettings() {
true,
agentNetworkEnabled,
);
const settings = useMemo<AgentNetworkSettings | null>(
() => (data ? settingsFromAPI(data) : null),
[data],
);
const notFound = !!error && (error as { code?: number }).code === 404;
// SWR keeps the previous data alongside the error (keepPreviousData), so a
// later 404 must not expose the stale settings; other transient errors keep
// them, which is what keepPreviousData is for.
const settings = useMemo<AgentNetworkSettings | null>(
() => (data && data.endpoint && !notFound ? settingsFromAPI(data) : null),
[data, notFound],
);
return {
settings,
isLoading: isLoading && !notFound,
@@ -660,7 +683,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
updates.metadataDisabled ?? existing.metadata_disabled,
models: updates.models
? toAPIModels(updates.models)
: existing.models ?? [],
: (existing.models ?? []),
enabled: updates.enabled ?? existing.enabled,
};
try {
@@ -745,7 +768,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
guardrail_ids: updates.guardrailIds ?? existing.guardrail_ids ?? [],
limits: updates.limits
? policyLimitsToAPI(updates.limits)
: existing.limits ?? policyLimitsToAPI(EMPTY_POLICY_LIMITS),
: (existing.limits ?? policyLimitsToAPI(EMPTY_POLICY_LIMITS)),
};
try {
await policiesApi.put(merged, `/${id}`);
+163 -115
View File
@@ -445,12 +445,13 @@ export default function AgentAccessLogTable({
</DataTableHeader>
),
// Same Reason cell as the flat view (deny reason, or the authorising
// policy link). A session is one policy decision in practice; surface a
// denied request when present, otherwise the first entry.
// 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.
cell: ({ row }) => {
const entries = row.original.entries;
const representative =
entries.find((e) => e.decision === "deny") ?? entries[0];
entries.find((e) => e.decision === "allow") ?? entries[0];
return representative ? (
<ReasonCell entry={representative} />
) : (
@@ -996,13 +997,10 @@ function ProviderCell({
);
}
function TokensCell({ entry }: { entry: AIAccessLogEntry }) {
if (
(entry.inputTokens === undefined || entry.inputTokens === 0) &&
(entry.outputTokens === undefined || entry.outputTokens === 0)
) {
return <EmptyRow />;
}
// 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.
@@ -1012,54 +1010,66 @@ function TokensCell({ entry }: { entry: AIAccessLogEntry }) {
cacheRead +
cacheWrite;
return (
<FullTooltip
content={
<div className={"text-xs flex flex-col gap-1"}>
<div className={"flex items-center gap-2 whitespace-nowrap"}>
<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"}>
<span className={"font-medium"}>
{entry.outputTokens.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>
}
>
<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
) {
return <EmptyRow />;
}
return (
<FullTooltip content={<TokenBreakdown entry={entry} />}>
<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.toLocaleString()}
{(entry.inputTokens ?? 0).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.toLocaleString()}
{(entry.outputTokens ?? 0).toLocaleString()}
</div>
</div>
</FullTooltip>
@@ -1076,32 +1086,89 @@ function CostRow({ amount, label }: { amount: number; label: string }) {
);
}
// CostCell renders the metered USD cost with a hover breakdown of the buckets
// it was billed from.
//
// 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 CostCell({
costUsd,
cacheCostUsd,
inputCostUsd,
cachedInputCostUsd,
cacheCreationCostUsd,
outputCostUsd,
}: {
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
@@ -1127,45 +1194,10 @@ function CostCell({
</span>
);
// Nothing to break out: no cache spend and no per-bucket split to show.
if (cache <= 0 && !hasBreakdown) return display;
if (!hasCostBreakdown(fields)) return display;
const cacheRead = cachedInputCostUsd ?? 0;
const cacheWrite = cacheCreationCostUsd ?? 0;
return (
<FullTooltip
content={
<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>
}
>
{display}
</FullTooltip>
<FullTooltip content={<CostBreakdown {...fields} />}>{display}</FullTooltip>
);
}
@@ -1323,7 +1355,13 @@ function SessionEntriesRow({ session }: { session: AIAccessLogSession }) {
>
{[...session.entries].reverse().map((entry) => {
const isOpen = open.includes(entry.id);
const total = (entry.inputTokens ?? 0) + (entry.outputTokens ?? 0);
// 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 isError = entry.decision === "deny" || entry.status >= 400;
return (
<div key={entry.id}>
@@ -1396,20 +1434,30 @@ function SessionEntriesRow({ session }: { session: AIAccessLogSession }) {
>
{entry.model || "—"}
</code>
<span
className={
"text-xs text-nb-gray-400 font-mono whitespace-nowrap tabular-nums w-[110px] text-right shrink-0"
}
<FullTooltip
disabled={total === 0}
content={<TokenBreakdown entry={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-[110px] text-right shrink-0"
}
>
{total.toLocaleString()} tokens
</span>
</FullTooltip>
<FullTooltip
disabled={!hasCostBreakdown(entry)}
content={<CostBreakdown {...entry} />}
>
${entry.costUsd.toFixed(4)}
</span>
<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>
</button>
{isOpen && (
<div className={"border-t border-nb-gray-800/70"}>
@@ -43,6 +43,17 @@ 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 = {
@@ -8,6 +8,12 @@ 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;
};
@@ -42,6 +48,13 @@ 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[];
};
-14
View File
@@ -30,7 +30,6 @@ import { OnboardingExplainDefaultPolicy } from "@/modules/onboarding/p2p/Onboard
import { OnboardingFirstDevice } from "@/modules/onboarding/p2p/OnboardingFirstDevice";
import { OnboardingSecondDevice } from "@/modules/onboarding/p2p/OnboardingSecondDevice";
import { OnboardingTestP2P } from "@/modules/onboarding/p2p/OnboardingTestP2P";
import { OnboardingDemoCall } from "@/modules/onboarding/OnboardingDemoCall";
export interface OnboardingState {
intent: Intent;
@@ -111,7 +110,6 @@ export const Onboarding = ({
const [onboarding, dispatch] = useReducer(onboardingReducer, initial);
const { step, intent } = onboarding;
const [demoCallModal, setDemoCallModal] = useState(false);
const [resource, setResource] = useState<NetworkResource>();
const [firstRoutingPeer, setFirstRoutingPeer] = useState<Peer>();
@@ -395,22 +393,10 @@ export const Onboarding = ({
payload: new Date().toISOString(),
});
}
const companySize = fields?.find(
(f) => f.name === "planned_users",
);
if (Number(companySize?.value) >= 50) {
setDemoCallModal(true);
}
}}
/>
)}
<OnboardingDemoCall
open={demoCallModal}
onOpenChange={setDemoCallModal}
/>
{step === 2 && (
<OnboardingIntent
useCases={useCases}
@@ -1,261 +0,0 @@
import Button from "@components/Button";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
} from "@components/modal/Modal";
import { CalendarClockIcon, CalendarIcon, Check } from "lucide-react";
import Image from "next/image";
import * as React from "react";
import Avatar from "@/assets/avatars/jack.jpeg";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import { useExperiment } from "@/cloud/cloud-hooks/useExperiment";
import { useAnalytics } from "@/contexts/AnalyticsProvider";
import { useAccount } from "@/modules/account/useAccount";
import { useLoggedInUser } from "@/contexts/UsersProvider";
type Props = {
open: boolean;
onOpenChange: React.Dispatch<React.SetStateAction<boolean>>;
};
const NAME = "Jack Carter";
const BOOK_A_DEMO_URL =
"https://meetings-eu1.hubspot.com/jack81/selfserve45-dashboard";
export const OnboardingDemoCall = ({ open, onOpenChange }: Props) => {
const { trackEventV2 } = useAnalytics();
const account = useAccount();
const { loggedInUser } = useLoggedInUser();
const [variant, variantKey] = useExperiment("onboarding-call", {
v1: {
title: "Book a Technical Overview (Not a Sales Call)",
desc: "You’ll meet with a solutions engineer who will walk through how NetBird works, answer your implementation questions - no slides, no hard sell.",
features: [
"Live walkthrough of setup and architecture",
"Implementation of use case, for your stack",
"Best practices and general overview",
],
cta: "Book Now",
cancel: "No Thanks",
},
v2: {
title: "Talk to our Solutions Engineer",
desc: (
<>
Get a 30-min technical overview. We’ll go over your specific use-case
and answer any technical questions you might have. <br /> We’re
offering this as a technical onboard support for you. <br /> This is
NOT a sales call.
</>
),
features: [],
cta: "Book Now",
cancel: "No Thanks",
},
v3: {
title: "Book a Technical Overview (Not a Sales Call)",
desc: (
<>
Get a 30-min technical overview. We’ll go over your specific use-case
and answer any technical questions you might have. <br /> We’re
offering this as a technical onboard support for you. <br /> This is
NOT a sales call.
</>
),
features: [],
cta: "Book Now",
cancel: "No Thanks",
},
v4: {
title: "Book a Technical Overview",
desc: "You’ll meet with a solutions engineer who will walk through how NetBird works, answer your implementation questions - no slides, no hard sell.",
features: [
"Live walkthrough of setup and architecture",
"Implementation of use case, for your stack",
"Best practices and general overview",
],
cta: "Book Now",
cancel: "No Thanks",
},
v5: {
title: "Talk to our Solutions Engineer",
desc: (
<>
Get a 30-min technical overview. We’ll go over your specific use-case
and answer any technical questions you might have. <br /> We’re
offering this as a technical onboard support for you.
</>
),
features: [],
cta: "Book Now",
cancel: "No Thanks",
},
v6: {
title: "Book a Technical Overview",
desc: (
<>
Get a 30-min technical overview. We’ll go over your specific use-case
and answer any technical questions you might have. <br /> We’re
offering this as a technical onboard support for you.
</>
),
features: [],
cta: "Book Now",
cancel: "No Thanks",
},
});
const openBookADemo = () => {
if (typeof window !== "undefined") {
trackEventV2(
"Onboarding Call Experiment",
variantKey,
account?.id,
loggedInUser?.id,
);
window.open(BOOK_A_DEMO_URL, "_blank");
onOpenChange(false);
}
};
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent
maxWidthClass={"max-w-lg relative !z-[70]"}
showClose={false}
>
<div className={"flex flex-col gap-4 overflow-hidden relative -top-3"}>
<div
className={
"flex items-center justify-center absolute bottom-0 w-full z-20"
}
>
<Image
src={Avatar}
alt={NAME}
height={50}
width={50}
className={"rounded-full"}
/>
</div>
<span
className={
"absolute inset-0 w-full h-full bg-gradient-to-b from-transparent to-nb-gray-950 z-10"
}
></span>
<div className={"flex gap-8 relative z-0 opacity-10 grayscale h-4"}>
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
</div>
<div
className={
"flex gap-8 -left-8 relative z-0 opacity-10 grayscale h-4"
}
>
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
</div>
<div className={"flex gap-4 relative z-0 opacity-10 grayscale h-4"}>
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
<NetBirdIcon size={20} />
</div>
</div>
<div
className={
"flex items-center justify-center flex-col px-8 gap-3 mt-1"
}
>
<div className={"text-xl font-medium text-center"}>
{variant?.title}
</div>
<div className={"text-sm text-nb-gray-300 text-center"}>
{variant?.desc}
</div>
{variant?.features?.length > 1 && (
<ul className="flex flex-col gap-3.5 mt-4 mb-3 w-full">
{variant?.features.map((feature, index) => (
<li
className="flex items-center gap-3 text-sm text-nb-gray-200"
key={index}
>
<Check size={16} className={"text-netbird shrink-0"} />
{feature}
</li>
))}
</ul>
)}
</div>
<ModalFooter separator={false} className={"gap-x-2"}>
<ModalClose asChild={true}>
<Button variant={"secondary"} className={"w-full"}>
{variant?.cancel}
</Button>
</ModalClose>
<Button
className={"w-full"}
variant={"primary"}
onClick={openBookADemo}
>
<CalendarIcon size={15} className={"shrink-0"} />
{variant?.cta}
</Button>
</ModalFooter>
<div
className={
"text-center z-0 mt-3 text-sm text-nb-gray-300 flex items-center justify-center gap-2 font-normal"
}
>
<CalendarClockIcon size={12} />
<div>
The call usually takes around
<span className={"font-medium"}> 30 minutes</span>
</div>
</div>
</ModalContent>
</Modal>
);
};
@@ -120,7 +120,10 @@ 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;
if (!isNetBirdCloud()) 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.
const isSignupFormPending = isNetBirdCloud()
? !!account?.onboarding?.signup_form_pending
: false;
@@ -95,6 +95,21 @@ const DISTROS: Distro[] = [
],
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",