mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49582fff74 | ||
|
|
af7745a03b | ||
|
|
19d82cc2ab | ||
|
|
b66cc4ccb7 | ||
|
|
1d408f2e7e |
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 |
@@ -17,6 +17,13 @@ function formatVersion(version: string): string {
|
||||
return version;
|
||||
}
|
||||
|
||||
// Self-hosted builds can carry a numeric build suffix (e.g. "0.76.3-31256681241")
|
||||
// that overflows the sidebar. Show the semver only and keep the rest for the
|
||||
// tooltip. Pre-release labels like "-rc.1" are left intact so they stay visible.
|
||||
function formatShortVersion(version: string): string {
|
||||
return formatVersion(version).replace(/^(v?\d+(?:\.\d+)*)-\d+$/, "$1");
|
||||
}
|
||||
|
||||
function compareVersions(current: string, latest: string): boolean {
|
||||
// Returns true if latest is newer than current
|
||||
if (!current || !latest) return false;
|
||||
@@ -98,9 +105,15 @@ const NavigationVersionInfoContent = () => {
|
||||
<div className="flex flex-col gap-1 text-nb-gray-400">
|
||||
<FullTooltip
|
||||
content={
|
||||
<span className="text-xs">
|
||||
Latest: {formatVersion(versionInfo.management_available_version)}
|
||||
</span>
|
||||
<div className="text-xs flex flex-col gap-1">
|
||||
<span>
|
||||
Installed:{" "}
|
||||
{formatVersion(versionInfo.management_current_version)}
|
||||
</span>
|
||||
<span>
|
||||
Latest: {formatVersion(versionInfo.management_available_version)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
side="top"
|
||||
className="w-full"
|
||||
@@ -108,15 +121,18 @@ const NavigationVersionInfoContent = () => {
|
||||
<div className="flex items-center justify-between w-full cursor-default">
|
||||
<span>Management</span>
|
||||
<span className="text-nb-gray-300 font-medium">
|
||||
{formatVersion(versionInfo.management_current_version)}
|
||||
{formatShortVersion(versionInfo.management_current_version)}
|
||||
</span>
|
||||
</div>
|
||||
</FullTooltip>
|
||||
<FullTooltip
|
||||
content={
|
||||
<span className="text-xs">
|
||||
Latest: {formatVersion(versionInfo.dashboard_available_version)}
|
||||
</span>
|
||||
<div className="text-xs flex flex-col gap-1">
|
||||
<span>Installed: {formatVersion(dashboardVersion)}</span>
|
||||
<span>
|
||||
Latest: {formatVersion(versionInfo.dashboard_available_version)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
side="top"
|
||||
className="w-full"
|
||||
@@ -124,7 +140,7 @@ const NavigationVersionInfoContent = () => {
|
||||
<div className="flex items-center justify-between w-full cursor-default">
|
||||
<span>Dashboard</span>
|
||||
<span className="text-nb-gray-300 font-medium">
|
||||
{formatVersion(dashboardVersion)}
|
||||
{formatShortVersion(dashboardVersion)}
|
||||
</span>
|
||||
</div>
|
||||
</FullTooltip>
|
||||
|
||||
@@ -57,9 +57,9 @@ export default function DataTableHeader({
|
||||
{children}
|
||||
{sorting &&
|
||||
(column.getIsSorted() === "desc" ? (
|
||||
<IconSortAscending size={16} />
|
||||
) : (
|
||||
<IconSortDescending size={16} />
|
||||
) : (
|
||||
<IconSortAscending size={16} />
|
||||
))}
|
||||
</div>
|
||||
</FullTooltip>
|
||||
|
||||
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}`);
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user