Skip to content
147 changes: 145 additions & 2 deletions e2e/tests/agent-network-focused-view.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,19 @@ import { loginToApp, navigateTo } from "../helpers/auth";

const SIGNUP_SOURCE_KEY = "netbird-signup-source";
const AGENT_NETWORK_SOURCE = "netbird.ai";
// Drives the NETBIRD_AGENT_NETWORK_ONLY / _ENABLED deployment flags against the
// test build (see testAgentNetworkOverride in utils/netbird.ts).
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";

type AgentNetworkConfig = "only" | "enabled" | "off";

type AccountMock = {
// undefined leaves the setting absent (as an un-onboarded account would be).
agentNetworkOnly?: boolean;
signupFormPending?: boolean;
// dashboardFeaturesAgentNetwork sets settings.dashboard_features.agent_network,
// which makes the Agent Network menu available without focusing the dashboard.
dashboardFeaturesAgentNetwork?: boolean;
};

// mockAccounts rewrites GET /accounts to inject the focused-view setting and
Expand Down Expand Up @@ -51,6 +59,11 @@ function mockAccounts(
} else {
body[0].settings.agent_network_only = applied;
}
if (initial.dashboardFeaturesAgentNetwork !== undefined) {
body[0].settings.dashboard_features = {
agent_network: initial.dashboardFeaturesAgentNetwork,
};
}
body[0].onboarding = {
...(body[0].onboarding ?? {}),
signup_form_pending: !!initial.signupFormPending,
Expand Down Expand Up @@ -78,7 +91,10 @@ function mockAccounts(
async function openWithAccount(
browser: Browser,
account: AccountMock,
opts: { signupSource?: boolean } = {},
opts: {
signupSource?: boolean;
agentNetworkConfig?: AgentNetworkConfig;
} = {},
): Promise<{
page: Page;
captured: { putBody?: any };
Expand All @@ -97,6 +113,16 @@ async function openWithAccount(
[SIGNUP_SOURCE_KEY, AGENT_NETWORK_SOURCE],
);
}
if (opts.agentNetworkConfig) {
await context.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch (e) {}
},
[AGENT_NETWORK_CONFIG_KEY, opts.agentNetworkConfig],
);
}
const page = await context.newPage();
const captured: { putBody?: any } = {};
mockAccounts(page, account, captured);
Expand All @@ -110,6 +136,13 @@ function navItem(page: Page, text: string) {
.getByText(text, { exact: true });
}

// navItemContaining matches a sidebar entry by a substring of its label. Some
// entries (Agent Network, Reverse Proxy) render a "Beta" badge inside the label
// when the dashboard is not focused, so exact text matching would miss them.
function navItemContaining(page: Page, text: string) {
return page.getByTestId("left-navigation-item").filter({ hasText: text });
}

// Regular dashboard sections that the focused view hides (gated on
// !agentNetworkOnly in Navigation.tsx).
const FOCUS_HIDDEN_NAV = ["Networks", "Reverse Proxy", "DNS", "Activity"];
Expand Down Expand Up @@ -179,6 +212,24 @@ test.describe.serial("Agent Network focused view @agent-network", () => {
}
});

test("dashboard_features.agent_network makes the menu available without focusing", async ({
browser,
}) => {
const { page, close } = await openWithAccount(browser, {
dashboardFeaturesAgentNetwork: true,
});
try {
// The Agent Network menu is available (the label carries a "Beta" badge
// outside focused mode, so match on the label substring)...
await expect(navItemContaining(page, "Agent Network")).toBeVisible();
// ...but the dashboard is not focused: a regular section that focused
// mode hides (Reverse Proxy) is still present.
await expect(navItemContaining(page, "Reverse Proxy")).toBeVisible();
} finally {
await close();
}
});

test("applies the focused view optimistically for a pending netbird.ai signup and persists it", async ({
browser,
}) => {
Expand All @@ -192,10 +243,14 @@ test.describe.serial("Agent Network focused view @agent-network", () => {
await expect(navItem(page, "Agent Network")).toBeVisible();
await expect(navItem(page, "Networks")).toHaveCount(0);

// The signup source is persisted as the account setting.
// The signup source is persisted: focused view plus the Agent Network
// menu flag, so turning focus off later keeps access to Agent Network.
await expect
.poll(() => captured.putBody?.settings?.agent_network_only)
.toBe(true);
expect(
captured.putBody?.settings?.dashboard_features?.agent_network,
).toBe(true);

// The focused view is retained after the write settles.
await expect(navItem(page, "Networks")).toHaveCount(0);
Expand All @@ -204,6 +259,94 @@ test.describe.serial("Agent Network focused view @agent-network", () => {
}
});

test("existing netbird.ai account gets dashboard_features, not the focused view", async ({
browser,
}) => {
// An existing account (signup form already submitted) with the netbird.ai
// source should have the Agent Network menu made available via
// dashboard_features, not the focused agent_network_only view.
const { page, captured, close } = await openWithAccount(
browser,
{ signupFormPending: false },
{ signupSource: true },
);
try {
await expect
.poll(
() => captured.putBody?.settings?.dashboard_features?.agent_network,
)
.toBe(true);
expect(captured.putBody?.settings?.agent_network_only).not.toBe(true);
} finally {
await close();
}
});

// Matrix of the two deployment flags (NETBIRD_AGENT_NETWORK_ONLY / _ENABLED,
// "off" = neither) crossed with the netbird.ai signup source, with no explicit
// account settings. The API defaults agent_network_only to false, so the
// deployment flags must still take effect. A present source implies a new
// signup (signup_form_pending), which focuses the dashboard optimistically
// regardless of the flags.
type Expectation = "hidden" | "available" | "focused";

const ENV_SIGNUP_MATRIX: {
env: AgentNetworkConfig;
signupSource: boolean;
expected: Expectation;
}[] = [
{ env: "off", signupSource: false, expected: "hidden" },
{ env: "off", signupSource: true, expected: "focused" },
{ env: "enabled", signupSource: false, expected: "available" },
{ env: "enabled", signupSource: true, expected: "focused" },
{ env: "only", signupSource: false, expected: "focused" },
{ env: "only", signupSource: true, expected: "focused" },
];

async function expectAgentNetworkState(page: Page, expected: Expectation) {
// Peers always renders for an owner, confirming the sidebar loaded.
await expect(navItem(page, "Peers")).toBeVisible();

if (expected === "hidden") {
await expect(navItemContaining(page, "Agent Network")).toHaveCount(0);
// The regular dashboard stays intact.
await expect(navItemContaining(page, "Reverse Proxy")).toBeVisible();
return;
}

await expect(navItemContaining(page, "Agent Network")).toBeVisible();

if (expected === "focused") {
for (const label of FOCUS_HIDDEN_NAV) {
await expect(navItem(page, label)).toHaveCount(0);
}
return;
}

// available: the menu shows alongside the regular dashboard.
await expect(navItemContaining(page, "Reverse Proxy")).toBeVisible();
}

for (const { env, signupSource, expected } of ENV_SIGNUP_MATRIX) {
const sourceLabel = signupSource ? "netbird.ai" : "none";
test(`env=${env} signup_source=${sourceLabel} -> ${expected}`, async ({
browser,
}) => {
const { page, close } = await openWithAccount(
browser,
// A source present means a fresh netbird.ai signup (form still pending);
// otherwise a plain account with no agent-network settings.
{ signupFormPending: signupSource },
{ agentNetworkConfig: env, signupSource },
);
try {
await expectAgentNetworkState(page, expected);
} finally {
await close();
}
});
}

test("exposes the focused-view toggle in client settings", async ({
browser,
}) => {
Expand Down
43 changes: 30 additions & 13 deletions src/cloud/contexts/NetBirdCloudProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,32 +51,47 @@ export const NetBirdCloudProvider = () => {
} catch (e) {}
}, []);

// Apply the netbird.ai signup source once the account is available. Only
// new accounts (signup form still pending) are switched to the Agent
// Network focused view — a stale flag from an existing user is discarded.
// Apply the netbird.ai signup source once the account is available. New
// accounts (signup form still pending) get the focused view
// (agent_network_only); existing accounts just get the Agent Network menu
// made available (dashboard_features.agent_network), leaving the rest of
// their dashboard intact.
useEffect(() => {
if (!account || signupSourceApplied.current) return;
try {
const source = localStorage.getItem(SIGNUP_SOURCE_LOCAL_STORAGE_KEY);
if (source !== AGENT_NETWORK_SIGNUP_SOURCE) return;

if (
account.onboarding?.signup_form_pending !== true ||
account.settings?.agent_network_only === true
) {
const isNewAccount = account.onboarding?.signup_form_pending === true;
const alreadyApplied = isNewAccount
? account.settings?.agent_network_only === true
: account.settings?.dashboard_features?.agent_network === true;

if (alreadyApplied) {
localStorage.removeItem(SIGNUP_SOURCE_LOCAL_STORAGE_KEY);
return;
}

signupSourceApplied.current = true;
// Always enable the Agent Network menu (dashboard_features). New accounts
// additionally get the focused view (agent_network_only). Keeping the
// menu flag set means that if a focused account later turns the focused
// view off, it keeps access to Agent Network rather than losing the menu.
const settings = {
...account.settings,
dashboard_features: {
...account.settings?.dashboard_features,
agent_network: true,
},
...(isNewAccount ? { agent_network_only: true } : {}),
};
notify({
title: "Agent Network",
description: "Agent Network focused view enabled for your account.",
description: isNewAccount
? "Agent Network focused view enabled for your account."
: "Agent Network enabled for your account.",
promise: accountRequest(
{
id: account.id,
settings: { ...account.settings, agent_network_only: true },
},
{ id: account.id, settings },
"/" + account.id,
).then(async () => {
// Revalidate before clearing the source key so the persisted
Expand All @@ -87,7 +102,9 @@ export const NetBirdCloudProvider = () => {
await mutate("/accounts");
localStorage.removeItem(SIGNUP_SOURCE_LOCAL_STORAGE_KEY);
}),
loadingMessage: "Enabling Agent Network focused view...",
loadingMessage: isNewAccount
? "Enabling Agent Network focused view..."
: "Enabling Agent Network...",
});
} catch (e) {}
}, [account]);
Expand Down
7 changes: 7 additions & 0 deletions src/interfaces/Account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,17 @@ export interface Account {
ipv6_enabled_groups?: string[];
network_range_v6?: string;
agent_network_only?: boolean;
dashboard_features?: AccountDashboardFeatures;
};
onboarding?: AccountOnboarding;
}

// AccountDashboardFeatures holds per-account dashboard section visibility
// overrides. Omitted keys follow the default dashboard behavior.
export interface AccountDashboardFeatures {
agent_network?: boolean;
}

export interface AccountOnboarding {
onboarding_flow_pending: boolean;
signup_form_pending: boolean;
Expand Down
17 changes: 12 additions & 5 deletions src/modules/agent-network/useAgentNetworkMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,20 @@ export const useAgentNetworkMode = () => {

return useMemo(() => {
const account = accounts?.[0];
// An explicit account setting (true or false) always wins so a user can
// opt out even when the deployment config enables focused mode. Only when
// the setting is absent do signup optimism and deployment config apply.
// Deployment config is a floor: NETBIRD_AGENT_NETWORK_ONLY focuses the
// dashboard regardless of the per-account setting. The management API always
// serializes agent_network_only (defaulting to false), so a false value
// can't be read as "unset" — without this floor it would silently override
// the env flag. When the config flag is off (e.g. cloud) the per-account
// setting decides, so a user can still opt in via signup or the toggle.
const setting = account?.settings?.agent_network_only;
const only =
setting ?? (isAgentNetworkSignupPending(account) || isAgentNetworkOnly());
const enabled = only || isAgentNetworkEnabled();
isAgentNetworkOnly() || (setting ?? isAgentNetworkSignupPending(account));
// dashboard_features.agent_network makes the Agent Network menu available
// alongside the full dashboard (unlike "only", which hides everything else).
const featureEnabled =
account?.settings?.dashboard_features?.agent_network === true;
const enabled = only || featureEnabled || isAgentNetworkEnabled();
const loading = permission.accounts.read ? isLoading : false;
return { only, enabled, loading } as const;
}, [accounts, isLoading, permission.accounts.read]);
Expand Down
27 changes: 27 additions & 0 deletions src/utils/netbird.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,35 @@ export const hasLicensedFlag = () => {
return config.licensed || isNetBirdCloud();
};

// testAgentNetworkOverride lets e2e tests drive the deployment flags
// NETBIRD_AGENT_NETWORK_ONLY / NETBIRD_AGENT_NETWORK_ENABLED against the test
// build via localStorage. "off" forces both flags off regardless of the build
// config. Inert outside test builds, where the APP_ENV check is replaced at
// compile time and tree-shaken away.
export const testAgentNetworkOverride = ():
| "only"
| "enabled"
| "off"
| undefined => {
if (process.env.APP_ENV !== "test") return undefined;
if (typeof window === "undefined") return undefined;
try {
const value = window.localStorage.getItem("netbird-test-agent-network");
if (value === "only" || value === "enabled" || value === "off") {
return value;
}
} catch (e) {
return undefined;
}
return undefined;
};

// isAgentNetworkOnly returns true for the dedicated Agent Network surface:
// the regular UI (network routing, DNS, reverse proxy, activity) is hidden and
// the Agent Network menu shows without a Beta badge.
export const isAgentNetworkOnly = () => {
const override = testAgentNetworkOverride();
if (override) return override === "only";
return config.agentNetworkOnly;
};

Expand All @@ -91,6 +116,8 @@ export const pkgsDownloadUrl = (path: string) =>
// (Providers, Policies, Usage & Logs) is available — in either the dedicated
// "only" mode or alongside the regular UI (where it carries a Beta badge).
export const isAgentNetworkEnabled = () => {
const override = testAgentNetworkOverride();
if (override) return override === "only" || override === "enabled";
return config.agentNetworkEnabled || config.agentNetworkOnly;
};

Expand Down
Loading