Compare commits

..
Author SHA1 Message Date
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
Maycon Santos 9fab9d7a83 Cover RPM-based distros and desktop app dependencies in the Linux tab (#733)
* Cover RPM-based distros and desktop app dependencies in the Linux tab

The manual-install section of the add-peer modal only covered Ubuntu via
APT. Replace the single Ubuntu accordion with a distro selector covering
Debian/Ubuntu (APT), Fedora (DNF), RHEL/AlmaLinux/Rocky (DNF), and Amazon
Linux (YUM).

The desktop app links GTK 4.10+ and WebKitGTK 6.0 but the released
netbird-ui packages do not declare them, so each desktop-app install line
names the packages explicitly and a callout states the minimum release
(Ubuntu 24.04, Debian 13, Fedora 43, RHEL 10 with EPEL). Distros without
the required stack get CLI-only instructions.

* Add e2e coverage for the Linux distro selector
2026-07-28 03:03:46 +09:00
20 changed files with 902 additions and 433 deletions

No files matched your search

+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 })
+122
View File
@@ -0,0 +1,122 @@
/**
* 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"),
);
});
});
+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_");
+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>
-11
View File
@@ -33,21 +33,12 @@ export const CrowdSecMode = {
export type CrowdSecMode = (typeof CrowdSecMode)[keyof typeof CrowdSecMode];
export const AppSecMode = {
OFF: "off",
ENFORCE: "enforce",
OBSERVE: "observe",
} as const;
export type AppSecMode = (typeof AppSecMode)[keyof typeof AppSecMode];
export interface AccessRestrictions {
allowed_cidrs?: string[];
blocked_cidrs?: string[];
allowed_countries?: string[];
blocked_countries?: string[];
crowdsec_mode?: CrowdSecMode;
appsec_mode?: AppSecMode;
}
export interface ReverseProxyMeta {
@@ -131,7 +122,6 @@ export interface ReverseProxyDomain {
supports_custom_ports?: boolean;
require_subdomain?: boolean;
supports_crowdsec?: boolean;
supports_appsec?: boolean;
supports_private?: boolean;
}
@@ -198,7 +188,6 @@ export interface ReverseProxyCluster {
supports_custom_ports?: boolean;
require_subdomain?: boolean;
supports_crowdsec?: boolean;
supports_appsec?: boolean;
// True when at least one connected proxy in this cluster is running embedded
// in a netbird client (`netbird proxy`) and serving over a WireGuard tunnel.
// Lets the dashboard distinguish per-peer / private clusters from centralised
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 = {
@@ -156,6 +163,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 +201,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,
}));
}
+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[];
};
@@ -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;
@@ -19,13 +19,8 @@ import {
SelectOption,
} from "@components/select/SelectDropdown";
import { CountrySelector } from "@/components/ui/CountrySelector";
import {
AccessRestrictions,
AppSecMode,
CrowdSecMode,
} from "@/interfaces/ReverseProxy";
import { AccessRestrictions, CrowdSecMode } from "@/interfaces/ReverseProxy";
import { ReverseProxyCrowdSecIPReputation } from "@/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation";
import { ReverseProxyAppSecInspection } from "@/modules/reverse-proxy/ReverseProxyAppSecInspection";
type AccessAction = "allow" | "block";
type AccessRuleType = "country" | "ip" | "cidr";
@@ -126,7 +121,6 @@ function restrictionsToRules(
function rulesToRestrictions(
rules: AccessRule[],
crowdsecMode?: CrowdSecMode,
appsecMode?: AppSecMode,
): AccessRestrictions | undefined {
const allowed_countries: string[] = [];
const blocked_countries: string[] = [];
@@ -150,14 +144,12 @@ function rulesToRestrictions(
}
const hasCrowdSec = crowdsecMode != null && crowdsecMode !== CrowdSecMode.OFF;
const hasAppSec = appsecMode != null && appsecMode !== AppSecMode.OFF;
const hasAny =
allowed_countries.length > 0 ||
blocked_countries.length > 0 ||
allowed_cidrs.length > 0 ||
blocked_cidrs.length > 0 ||
hasCrowdSec ||
hasAppSec;
hasCrowdSec;
if (!hasAny) return undefined;
@@ -167,7 +159,6 @@ function rulesToRestrictions(
...(allowed_cidrs.length > 0 && { allowed_cidrs }),
...(blocked_cidrs.length > 0 && { blocked_cidrs }),
...(hasCrowdSec && { crowdsec_mode: crowdsecMode }),
...(hasAppSec && { appsec_mode: appsecMode }),
};
}
@@ -176,7 +167,6 @@ type Props = {
onChange: (value: AccessRestrictions | undefined) => void;
onValidationChange?: (hasErrors: boolean) => void;
supportsCrowdSec?: boolean;
supportsAppSec?: boolean;
};
function validateRule(rule: AccessRule): string {
@@ -203,7 +193,6 @@ export const ReverseProxyAccessControlRules = ({
onChange,
onValidationChange,
supportsCrowdSec,
supportsAppSec,
}: Props) => {
const [rules, dispatch] = useReducer(
rulesReducer,
@@ -211,10 +200,6 @@ export const ReverseProxyAccessControlRules = ({
restrictionsToRules,
);
const [appsecMode, setAppsecMode] = useState<AppSecMode>(
value?.appsec_mode ?? AppSecMode.OFF,
);
const [crowdsecMode, setCrowdsecMode] = useState<CrowdSecMode>(
value?.crowdsec_mode ?? CrowdSecMode.OFF,
);
@@ -242,14 +227,8 @@ export const ReverseProxyAccessControlRules = ({
}, [supportsCrowdSec]);
useEffect(() => {
if (!supportsAppSec) {
setAppsecMode(AppSecMode.OFF);
}
}, [supportsAppSec]);
useEffect(() => {
onChangeRef.current(rulesToRestrictions(rules, crowdsecMode, appsecMode));
}, [rules, crowdsecMode, appsecMode]);
onChangeRef.current(rulesToRestrictions(rules, crowdsecMode));
}, [rules, crowdsecMode]);
useEffect(() => {
onValidationChangeRef.current?.(hasErrors);
@@ -263,12 +242,6 @@ export const ReverseProxyAccessControlRules = ({
onChange={setCrowdsecMode}
/>
)}
{supportsAppSec && (
<ReverseProxyAppSecInspection
value={appsecMode}
onChange={setAppsecMode}
/>
)}
<div>
<Label>Access Control Rules</Label>
<HelpText>
@@ -1,107 +0,0 @@
import * as React from "react";
import { ReactNode } from "react";
import { Label } from "@components/Label";
import HelpText from "@components/HelpText";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/Select";
import { EyeIcon, PowerOffIcon, ShieldCheckIcon } from "lucide-react";
import { HelpTooltip } from "@components/HelpTooltip";
import { AppSecMode } from "@/interfaces/ReverseProxy";
import Image from "next/image";
import CrowdSecIconImage from "@/assets/integrations/crowdsec.png";
type Props = {
value: AppSecMode;
onChange: (value: AppSecMode) => void;
};
type AppSecOption = {
label: string;
description?: string;
icon: ReactNode;
};
const APPSEC_OPTIONS: Record<AppSecMode, AppSecOption> = {
[AppSecMode.OFF]: {
label: "Disabled",
icon: <PowerOffIcon size={14} />,
},
[AppSecMode.ENFORCE]: {
label: "Enforce",
description:
"Flagged requests are blocked with 403. Every request is inspected before it reaches the service, and requests are denied if the engine is unreachable (fail-closed).",
icon: <ShieldCheckIcon size={14} />,
},
[AppSecMode.OBSERVE]: {
label: "Observe",
description:
"Flagged requests are logged but still forwarded. Use this to evaluate the rules against real traffic before enforcing.",
icon: <EyeIcon size={14} />,
},
};
export const ReverseProxyAppSecInspection = ({ value, onChange }: Props) => {
const selected = APPSEC_OPTIONS[value];
return (
<div className="flex items-center gap-0 justify-between mb-6">
<div className="flex gap-4">
<div
className={
"h-12 w-12 flex items-center justify-center rounded-md bg-nb-gray-900/70 p-2 border border-nb-gray-900/70 shrink-0 relative"
}
>
<Image
src={CrowdSecIconImage}
alt={"CrowdSec"}
className={"rounded-[4px]"}
/>
</div>
<div>
<Label>CrowdSec AppSec (WAF)</Label>
<HelpText>
Inspect HTTP requests for exploits.{" "}
<b className={"text-white"}>Enforce</b> to block them or{" "}
<b className={"text-white"}>Observe</b> to only log without
blocking.
</HelpText>
</div>
</div>
<Select value={value} onValueChange={(v) => onChange(v as AppSecMode)}>
<SelectTrigger className="w-[260px]" data-testid="appsec-mode-trigger">
<div className="flex items-center gap-2 whitespace-nowrap">
{selected.icon}
<SelectValue />
</div>
</SelectTrigger>
<SelectContent>
{Object.entries(APPSEC_OPTIONS).map(([mode, config]) => (
<SelectItem
key={mode}
value={mode}
data-testid={`appsec-mode-${mode}`}
extra={
config.description ? (
<HelpTooltip
triggerClassName="ml-[0.01rem]"
align="center"
side="right"
content={<>{config.description}</>}
/>
) : undefined
}
>
<span className="whitespace-nowrap">{config.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
@@ -814,7 +814,6 @@ export default function ReverseProxyModal({
onChange={setAccessRestrictions}
onValidationChange={setAccessControlHasErrors}
supportsCrowdSec={selectedDomain?.supports_crowdsec}
supportsAppSec={!isL4Mode && selectedDomain?.supports_appsec}
/>
</div>
</TabsContent>
@@ -47,15 +47,6 @@ export default function ClustersFeaturesCell({ cluster }: Readonly<Props>) {
icon: <ShieldAlert size={14} className={"text-green-500"} />,
});
}
if (cluster.supports_appsec) {
features.push({
key: "appsec",
label: "AppSec (WAF)",
description:
"Cluster has a CrowdSec AppSec endpoint configured across all active proxies, so services can inspect HTTP requests for exploits.",
icon: <ShieldAlert size={14} className={"text-green-500"} />,
});
}
if (cluster.private) {
features.push({
key: "private",
@@ -65,7 +56,8 @@ export default function ClustersFeaturesCell({ cluster }: Readonly<Props>) {
Lets you publish services that are only reachable from peers in your
NetBird network. Required for{" "}
<span className={"font-medium text-white"}>NetBird-Only Access</span>{" "}
and <span className={"font-medium text-white"}>Proxy Cluster</span>{" "}
and{" "}
<span className={"font-medium text-white"}>Proxy Cluster</span>{" "}
target types.
</>
),
@@ -111,21 +111,6 @@ export const ReverseProxyEventsAuthMethodCell = ({
icon: <ShieldOff size={12} />,
label: "CrowdSec Unavailable",
};
case "appsec_ban":
return {
icon: <ShieldAlert size={12} />,
label: "AppSec Ban",
};
case "appsec_captcha":
return {
icon: <ShieldAlert size={12} />,
label: "AppSec Captcha",
};
case "appsec_unavailable":
return {
icon: <ShieldOff size={12} />,
label: "AppSec Unavailable",
};
default:
return {
icon: null,
@@ -9,9 +9,6 @@ const VERDICT_LABELS: Record<string, string> = {
crowdsec_ban: "Ban",
crowdsec_captcha: "Captcha",
crowdsec_throttle: "Throttle",
appsec_ban: "Ban",
appsec_captcha: "Captcha",
appsec_unavailable: "Unavailable",
};
type Props = {
@@ -20,16 +17,12 @@ type Props = {
export const ReverseProxyEventsReasonCell = ({ event }: Props) => {
const metadata = event.metadata;
// Only one of the two can be the reason a request was flagged without being
// blocked, since an enforced block sets auth_method_used instead.
const source = metadata?.appsec_verdict ? "appsec" : "crowdsec";
const verdictKey = `${source}_verdict`;
const verdict = metadata?.[verdictKey];
const verdict = metadata?.crowdsec_verdict;
if (verdict && !event.auth_method_used?.startsWith(`${source}_`)) {
if (verdict && !event.auth_method_used?.startsWith("crowdsec_")) {
const verdictLabel = VERDICT_LABELS[verdict] ?? verdict;
const metaEntries = Object.entries(metadata!).filter(
([k]) => k !== verdictKey,
([k]) => k !== "crowdsec_verdict",
);
return (
@@ -56,8 +49,7 @@ export const ReverseProxyEventsReasonCell = ({ event }: Props) => {
<div className="px-3 py-2">
<Badge variant="gray" className="gap-1.5">
<ShieldAlert size={12} className="text-yellow-500" />
{source === "appsec" ? "AppSec" : "CrowdSec"} Observe:{" "}
{verdictLabel}
CrowdSec Observe: {verdictLabel}
</Badge>
</div>
</FullTooltip>
@@ -20,7 +20,7 @@ import { useMemo } from "react";
import { useCountries } from "@/contexts/CountryProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { AppSecMode, CrowdSecMode, ReverseProxy } from "@/interfaces/ReverseProxy";
import { CrowdSecMode, ReverseProxy } from "@/interfaces/ReverseProxy";
type RuleEntry = {
key: string;
@@ -44,35 +44,21 @@ export default function ReverseProxyAccessControlCell({
const canConfigure = !!permission?.services?.update;
const restrictions = reverseProxy.access_restrictions;
// Services on a custom domain carry its target_cluster in proxy_cluster, so
// matching on domain alone misses them and reports no capability.
const clusterDomain = domains?.find(
(d) =>
d.domain === reverseProxy.proxy_cluster ||
d.target_cluster === reverseProxy.proxy_cluster,
);
const supportsCrowdSec = clusterDomain?.supports_crowdsec;
const supportsCrowdSec = domains?.find(
(d) => d.domain === reverseProxy.proxy_cluster,
)?.supports_crowdsec;
const hasCrowdSec =
supportsCrowdSec &&
restrictions?.crowdsec_mode != null &&
restrictions.crowdsec_mode !== CrowdSecMode.OFF;
const supportsAppSec = clusterDomain?.supports_appsec;
const hasAppSec =
supportsAppSec &&
restrictions?.appsec_mode != null &&
restrictions.appsec_mode !== AppSecMode.OFF;
const ruleCount =
(restrictions?.allowed_cidrs?.length ?? 0) +
(restrictions?.blocked_cidrs?.length ?? 0) +
(restrictions?.allowed_countries?.length ?? 0) +
(restrictions?.blocked_countries?.length ?? 0) +
(hasCrowdSec ? 1 : 0) +
(hasAppSec ? 1 : 0);
(hasCrowdSec ? 1 : 0);
const rulesBadge = (
<Badge
@@ -177,20 +163,8 @@ export default function ReverseProxyAccessControlCell({
});
}
if (hasAppSec) {
entries.push({
key: "appsec",
label: "AppSec (WAF)",
Icon: ShieldAlert,
value:
restrictions?.appsec_mode === AppSecMode.ENFORCE
? "Enforce"
: "Observe",
});
}
return entries;
}, [restrictions, countries, hasCrowdSec, hasAppSec]);
}, [restrictions, countries, hasCrowdSec]);
const showRulesHover = ruleGroups.length > 0;
+149 -30
View File
@@ -4,13 +4,14 @@ 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 { IconBrandUbuntu } from "@tabler/icons-react";
import { TerminalSquareIcon } from "lucide-react";
import React from "react";
import { PackageIcon, TerminalSquareIcon } from "lucide-react";
import React, { useState } from "react";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import {
NetBirdUpCommand,
@@ -25,6 +26,99 @@ 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,
@@ -32,8 +126,20 @@ 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>
@@ -66,43 +172,56 @@ export default function LinuxTab({
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger>
<IconBrandUbuntu size={16} />
Install manually on Ubuntu
<PackageIcon size={16} />
Install manually with a package manager
</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>
<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 codeToCopy={distro.repository.join("\n")}>
{distro.repository.map((line) => (
<Code.Line key={line}>{line}</Code.Line>
))}
</Code>
</Steps.Step>
<Steps.Step step={2}>
<p>Install NetBird</p>
<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 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>
<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>