Compare commits

...
27 Commits
Author SHA1 Message Date
Eduard GertandClaude Opus 4.8 d5f61efc1a Fix network resource policy creation with single peer source (#721)
When creating a network resource and adding a policy with a single peer
(sourceResource) as the source, the payload included both an empty
`sources: []` array and `sourceResource`, causing the backend to reject
it with "specify either sources or source resources not both".

`createPolicyForResource` always computed `sources` as an array while the
`...rule` spread carried `sourceResource` through. This mirrors the
existing destinations handling: gate `sources` on `rule.sourceResource`
so it becomes undefined (and is dropped during serialization) when a
source resource is set.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 11:16:36 +02:00
Maycon Santos 3503141c56 Adjust license cache ttl to 10 minutes (#718) 2026-07-20 11:03:14 +02:00
Misha Bragin a27e4654cf Fix IdP cards spacing (#717) 2026-07-16 14:41:59 +02:00
Misha Bragin b9569f48d5 Add clusters view to agent network (#714) 2026-07-15 23:22:05 +02:00
Viktor Liu 9694bb753d Show RDP for Linux and FreeBSD peers and harden the browser RDP client (#710) 2026-07-15 12:29:54 +02:00
Maycon Santos 377d239a01 Make Agent Network menu available via dashboard_features (#709)
* Make Agent Network menu available via dashboard_features.agent_network

Wire the new dashboard_features account setting into the dashboard at the API
level: add it to the Account settings interface (so settings updates round-trip
it) and have useAgentNetworkMode treat dashboard_features.agent_network === true
as enabling the Agent Network menu alongside the full dashboard. Unlike
agent_network_only it does not focus the dashboard, and it does not trigger the
onboarding flow. No settings-menu UI yet; the field is set via the API.

* Add e2e coverage for dashboard_features.agent_network menu availability

* Set dashboard_features for existing netbird.ai accounts

The signup-source flow only handled new accounts (agent_network_only) and
discarded the source for existing accounts, so nothing set dashboard_features.
Branch on account existence: new accounts get the focused view, existing
accounts get the Agent Network menu made available via
dashboard_features.agent_network. Adds e2e coverage for the existing-account
write.

* Also enable Agent Network menu for new netbird.ai accounts

New accounts got agent_network_only (focused) but not
dashboard_features.agent_network, so turning the focused view off later would
remove the Agent Network menu entirely. Set both on new accounts so disabling
focus degrades to menu-available rather than losing access.

* Match badged nav labels by substring in dashboard_features e2e

* Treat agent network deployment config as a floor over account setting

* Add agent network env x signup-source e2e matrix with a config override
2026-07-13 17:17:36 +02:00
Maycon Santos a266fff89d Focus dashboard on Agent Network for netbird.ai signups (#708)
Accounts that sign up through netbird.ai are focused on the Agent Network
  surface and taken through the Agent Network onboarding. Reversible by the user
  and a no-op for every other account.
  
  - Capture ?signup_source=netbird.ai pre-auth (survives the OIDC redirect); once
    authenticated, mark new accounts with settings.agent_network_only, awaiting
    /accounts revalidation before clearing the source so the view never flips.
  - useAgentNetworkMode() resolves the mode with nullish semantics: an explicit
    account setting (true or false) wins over deployment config; when absent, a
    pending netbird.ai signup or NETBIRD_AGENT_NETWORK_ONLY/_ENABLED decides. 
    Purely additive — unchanged behavior when the setting is absent. 
  - Navigation, the agent-network route guard, control-center, and fetch gates
    read the resolver instead of the config flags.
  - Onboarding: the form is chosen from signup_source synchronously, so netbird.ai
    arrivals see the Agent Network signup form (now shown on cloud) and never the
    regular form, even under a slow backend.
  - Client Settings gains an "Agent Network focused view" opt-out toggle.
  - E2E coverage for focused-view menu/routes and onboarding form selection.
2026-07-12 21:16:05 +02:00
Maycon Santos 8ed69f21c5 [reverse proxy] Cluster one click deploy (#707) 2026-07-12 15:05:02 +02:00
Eduard Gert 58d1035b16 Remove experimental flag from lazy connections (#706) 2026-07-10 14:29:18 +02:00
Pascal Fischer 991234785c new traffic filters (#704) 2026-07-09 14:26:40 +02:00
Eduard Gert f2ad889fdc Revert using tenants own currency and instead use MSP currency (#703) 2026-07-08 10:47:17 +02:00
Jack CarterandEduard Gert 1f219c4ffc Default billing to EUR, preserve USD for active subscribers (#702)
* feat: default billing to EUR only, preserve USD for active subscribers

Removes the USD/EUR currency selector from Plans & Billing. New
subscriptions, MSP tenants, and distributor customers now default to
EUR; any existing active USD subscription keeps its real currency so
its price stays accurate.

Also fixes a latent bug where MSP/distributor per-tenant pricing was
computed off the admin's own account currency instead of each
tenant's, pins AWS Marketplace checkout to USD since it's a separate
billing rail, and removes an unused dead TotalCost stub from the MSP
Tenants table.

* move currency helper into provider

* force usd for aws

* fix flaky setup key test

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-07-07 16:45:31 +02:00
Viktor Liu 87b3a08adc Surface readable RDP errors, keep client connected on retry, bump wasm to v0.74.2 (#701) 2026-07-07 14:11:59 +02:00
Maycon SantosandClaude Opus 4.8 cfd0037cef Show management API request id in error notifications (#698)
* Show management API request id in error notifications

Newer management servers return an X-Request-Id header on every response,
including errors. Surface it in the error toast so it can be quoted in
support tickets.

- api.tsx: read X-Request-Id from the response and attach it to the
  rejected ErrorResponse (both the JSON-body and status-text paths).
- Notification.tsx: render a copyable "Request ID" line whenever an error
  carries one, covering the promise-based mutation notifications.
- ErrorBoundary.tsx: thread the request id through for GET/fetch errors.

The line is only shown when the header is present, so older servers that
do not send it are unaffected. Adds an e2e spec covering both cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016B2yLBeDK3kvX2FvhqcWk8

* docs: add request-id error toast screenshots for PR

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016B2yLBeDK3kvX2FvhqcWk8

* test: expose X-Request-Id via CORS in e2e mock

The dashboard reads the request id from a cross-origin response, so the
browser only surfaces the header to JS when the response lists it in
Access-Control-Expose-Headers. Mirror a CORS-enabled management server in
the mock so the test exercises the dashboard logic rather than the header
being filtered out cross-origin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016B2yLBeDK3kvX2FvhqcWk8

* test: assert error toast via data-testid instead of text

Add a notification-title testid to the toast and target it with
getByTestId in the request-id spec, following the repo convention of
selecting by data-testid rather than brittle visible text.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 14:02:16 +02:00
Pascal Fischer ac7d578bd7 aggregated traffic events (#693) 2026-07-07 11:36:25 +02:00
Misha Bragin 8d41de995c Fix Skip TLS doc link and model pricing format (#699) 2026-07-03 07:03:42 +02:00
Misha Bragin 30dd75d95d Feature/add skip tls verification (#697) 2026-07-01 22:25:05 +02:00
Zoltan Papp bfa064a19d Add Metrics settings tab with metrics push toggle (#613)
* Add Metrics settings tab with metrics push toggle

Add a dedicated Metrics tab to Settings with a toggle for enabling
anonymous performance metrics collection. Moves metrics out of the
Clients tab into its own tab with description and docs link.

* Update docs link
2026-07-01 19:00:00 +02:00
Misha Bragin db73d18370 Feature/add provider session (#695) 2026-07-01 16:32:44 +02:00
Misha Bragin ba32178425 Fix peer table page reset when switching kind (#696) 2026-07-01 16:09:15 +02:00
4ea4fd4a4c Reload nginx after patching CSP header (#687)
* fix(docker): reload nginx after patching CSP header

nginx is started by supervisord (priority 100) before init_react_envs.sh
(priority 201) and is never reloaded afterwards, so it keeps serving the
static default.conf CSP that has no connect-src. Over plain HTTP this blocks
OIDC discovery and the management API, breaking browser SSH/RDP auth.

Reload nginx at the end of the init script so the dynamically built CSP
header takes effect.

* Update docker/init_react_envs.sh

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix(docker): log nginx -t output on failed CSP reload

When the reload never succeeds, run nginx -t before exiting so the logs show
whether the patched config is invalid or nginx never came up.

* conditionally emit `upgrade-insecure-requests` in CSP

---------

Co-authored-by: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: mlsmaycon <mlsmaycon@gmail.com>
2026-07-01 15:32:22 +02:00
Bethuel Mmbaga deaae9159c Show reason for peer login expiration events (#694) 2026-07-01 12:32:11 +03:00
Bethuel Mmbaga d7fd24244d Prevent gated useFetchApi hooks from overwriting shared cache entries (#692) 2026-06-30 17:44:38 +03:00
Misha Bragin 9230b3e4e3 Add Claude provider selector and Bedrock (#691) 2026-06-30 11:50:15 +02:00
Misha Bragin 19ad8d6806 Prefill Vertex endpoint (#689) 2026-06-29 10:53:07 +02:00
Misha Bragin 38fc2970e6 Support Vertex keyfile upload and agent config (#688) 2026-06-28 22:34:54 +02:00
Misha Bragin 4ec4d29e84 The empty-state peer card now renders AddPeerDropdown 2026-06-28 12:12:11 +02:00
79 changed files with 4891 additions and 1157 deletions

No files matched your search

+3 -3
View File
@@ -39,7 +39,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
tag: v0.0.2
fileName: "*.ts"
out-file-path: 'public/ironrdp-pkg'
@@ -48,7 +48,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
tag: v0.0.2
fileName: "*.js"
out-file-path: 'public/ironrdp-pkg'
@@ -57,7 +57,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
tag: v0.0.2
fileName: "ironrdp_web_bg.wasm"
out-file-path: 'public/ironrdp-pkg'
@@ -0,0 +1,34 @@
name: Sync deploy templates to S3
on:
push:
branches: [main]
paths:
- "templates/reverse-proxy/**"
permissions:
id-token: write
contents: read
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.DEPLOY_TEMPLATES_ROLE_ARN }}
aws-region: eu-central-1
- name: Validate CloudFormation template
run: |
aws cloudformation validate-template \
--template-body file://templates/reverse-proxy/netbird-proxy-cfn.yaml
- name: Upload CloudFormation template
run: |
aws s3 cp templates/reverse-proxy/netbird-proxy-cfn.yaml \
s3://netbird-deploy-templates/templates/netbird-proxy-cfn.yaml
+78
View File
@@ -0,0 +1,78 @@
name: Test cloud deploy config
on:
pull_request:
paths:
- "docker/init_react_envs.sh"
- "templates/reverse-proxy/**"
- "src/modules/reverse-proxy/clusters/ClusterCloudDeploy.tsx"
- ".github/workflows/test-cloud-deploy.yml"
permissions:
contents: read
jobs:
csp-connect-src:
name: CSP allows cloud provider APIs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install nginx
run: |
sudo apt-get update
sudo apt-get install -y nginx gettext-base
- name: Prepare nginx layout expected by the init script
run: |
sudo mkdir -p /etc/nginx/http.d /usr/share/nginx/html
echo 'add_header Content-Security-Policy "placeholder" always;' | sudo tee /etc/nginx/http.d/default.conf
sudo touch /usr/share/nginx/html/OidcTrustedDomains.js.tmpl
sudo systemctl start nginx
- name: Run init script with production-like env
env:
AUTH_AUTHORITY: https://auth.example.com
AUTH_CLIENT_ID: test-client
AUTH_AUDIENCE: test-audience
AUTH_SUPPORTED_SCOPES: openid profile email
USE_AUTH0: "false"
NETBIRD_MGMT_API_ENDPOINT: https://api.example.com
run: sudo -E bash docker/init_react_envs.sh
- name: Assert provider APIs are in connect-src
run: |
CSP=$(grep -o 'Content-Security-Policy "[^"]*"' /etc/nginx/http.d/default.conf)
echo "Generated CSP: $CSP"
CONNECT_SRC=$(echo "$CSP" | sed 's/.*connect-src \([^;]*\);.*/\1/')
echo "connect-src: $CONNECT_SRC"
status=0
for origin in https://api.hetzner.cloud https://api.digitalocean.com; do
if echo "$CONNECT_SRC" | grep -qF "$origin"; then
echo "OK: $origin"
else
echo "MISSING in connect-src: $origin"
status=1
fi
done
exit $status
cfn-template-url:
name: CloudFormation template URL is live
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Extract template URL from component and check it
run: |
URL=$(grep -o 'https://[^"]*netbird-proxy-cfn\.yaml' src/modules/reverse-proxy/clusters/ClusterCloudDeploy.tsx)
echo "CFN_TEMPLATE_URL: $URL"
test -n "$URL"
# Non-blocking: the template is published by sync-deploy-templates on
# the default branch, so it may not exist yet for a PR that only edits
# the URL. Warn instead of failing the check.
if curl -sfI "$URL" >/dev/null; then
echo "OK: template URL is live"
else
echo "::warning::CloudFormation template URL is not reachable yet: $URL"
fi
+34 -3
View File
@@ -80,7 +80,7 @@ echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
FIRST_PARTY_CSP="pkgs.netbird.io"
FIRST_PARTY_CSP_CONNECT_SRC="wss://*.netbird.io"
THIRD_PARTY_CSP="*.licdn.com *.linkedin.com *.vector.co *.sibforms.com *.hotjar.com *.hotjar.io *.redditstatic.com pixel-config.reddit.com *.clarity.ms c.bing.com *.microsoft.com googleads.g.doubleclick.net pagead2.googlesyndication.com www.google.com www.googleadservices.com *.google-analytics.com *.googletagmanager.com analytics.google.com *.hubapi.com *.hs-banner.com *.hubspot.com *.hubspot.net js.hs-analytics.com *.hsforms.net *.hscollectedforms.net *.hs-analytics.net *.hsforms.com track.hubspot.com *.hsadspixel.net static.hsappstatic.net"
THIRD_PARTY_CSP_CONNECT_SRC="https://api.github.com/repos/netbirdio/netbird/releases/latest https://raw.githubusercontent.com/netbirdio/dashboard/ wss://ws.hotjar.com"
THIRD_PARTY_CSP_CONNECT_SRC="https://api.github.com/repos/netbirdio/netbird/releases/latest https://raw.githubusercontent.com/netbirdio/dashboard/ wss://ws.hotjar.com https://api.hetzner.cloud https://api.digitalocean.com"
THIRD_PARTY_CSP_SCRIPT_SRC="'sha256-7knV6EIjKUvCpYWE2rCYx8dYV2WCNb2bpTuitFXzBcA=' *.hs-scripts.com"
CSP_DOMAINS=""
@@ -141,8 +141,18 @@ CSP_CONNECT_SRC=$(echo $CSP_CONNECT_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' |
CSP_FRAME_SRC=$(echo $CSP_FRAME_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
CSP_SCRIPT_SRC=$(echo $CSP_SCRIPT_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
# upgrade-insecure-requests tells the browser to rewrite every http subresource,
# fetch and WebSocket to https/wss. That is correct for https deployments but breaks
# plain-http ones (e.g. local or self-hosted over http), where it would rewrite the
# working http/ws endpoints to unreachable https/wss. Emit it only when the backend
# is served over https, matching the ws:// vs wss:// scheme logic above.
CSP_UPGRADE_INSECURE=" upgrade-insecure-requests;"
if [[ "$NETBIRD_MGMT_API_ENDPOINT" == http://* || "$AUTH_AUTHORITY" == http://* ]]; then
CSP_UPGRADE_INSECURE=""
fi
# Update CSP in nginx config
CSP_POLICY="default-src 'none'; connect-src 'self' $CSP_CONNECT_SRC; frame-src 'self' $CSP_FRAME_SRC; script-src 'self' 'wasm-unsafe-eval' $CSP_SCRIPT_SRC; font-src 'self'; img-src * data:; manifest-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests;"
CSP_POLICY="default-src 'none'; connect-src 'self' $CSP_CONNECT_SRC; frame-src 'self' $CSP_FRAME_SRC; script-src 'self' 'wasm-unsafe-eval' $CSP_SCRIPT_SRC; font-src 'self'; img-src * data:; manifest-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'; base-uri 'self'; form-action 'self';$CSP_UPGRADE_INSECURE"
CSP_HEADER="add_header Content-Security-Policy \"$CSP_POLICY\" always;"
echo "CSP header: $CSP_HEADER"
@@ -161,4 +171,25 @@ for f in $(grep -R -l AUTH_SUPPORTED_SCOPES /usr/share/nginx/html); do
cp "$f" "$f".copy
envsubst "$ENV_STR" < "$f".copy > "$f"
rm "$f".copy
done
done
# Reload nginx so the patched CSP header takes effect.
# supervisord starts nginx (priority 100) before this script (priority 201) and
# never reloads it, so without this nginx keeps serving the static default.conf
# CSP that has no connect-src, breaking OIDC discovery over plain HTTP.
reloaded=false
for i in $(seq 1 10); do
if nginx -s reload 2>/dev/null; then
echo "Reloaded nginx to apply updated CSP configuration"
reloaded=true
break
fi
echo "Waiting for nginx to be ready before reload (attempt $i)..."
sleep 1
done
if [[ "$reloaded" != true ]]; then
echo "Failed to reload nginx after patching CSP header" >&2
nginx -t || true
exit 1
fi
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+17 -4
View File
@@ -20,7 +20,11 @@ const credentials: Record<TestUser, { username: string; password: string }> = {
/**
* Navigate to the app, authenticate via Zitadel, and wait for the app to load.
*/
export async function loginToApp(page: Page, user: TestUser = "owner") {
export async function loginToApp(
page: Page,
user: TestUser = "owner",
opts: { expectOnboarding?: boolean } = {},
) {
const { username, password } = credentials[user];
await page.goto("/");
@@ -39,7 +43,9 @@ export async function loginToApp(page: Page, user: TestUser = "owner") {
const which = await Promise.race([
appReady.waitFor({ timeout: 20_000 }).then(() => "app" as const),
setupModal.waitFor({ timeout: 20_000 }).then(() => "modal" as const),
approvalPending.waitFor({ timeout: 20_000 }).then(() => "approval" as const),
approvalPending
.waitFor({ timeout: 20_000 })
.then(() => "approval" as const),
onboarding.waitFor({ timeout: 20_000 }).then(() => "onboarding" as const),
selectAccount.waitFor({ timeout: 20_000 }).then(() => "select" as const),
loginInput.waitFor({ timeout: 20_000 }).then(() => "login" as const),
@@ -51,6 +57,9 @@ export async function loginToApp(page: Page, user: TestUser = "owner") {
}
if (which === "modal") {
// Onboarding tests render a full-screen overlay over the setup modal;
// clicking modal-close would be pointer-intercepted, so leave it be.
if (opts.expectOnboarding) return;
await setupModal.getByTestId("modal-close").click();
await expect(setupModal).not.toBeVisible();
return;
@@ -89,8 +98,12 @@ export async function loginToApp(page: Page, user: TestUser = "owner") {
onboarding.waitFor({ timeout: 15_000 }),
]);
// Dismiss setup modal if present
if (await setupModal.isVisible().catch(() => false)) {
// Dismiss setup modal if present, unless an onboarding overlay is expected
// on top of it (clicking through it would be pointer-intercepted).
if (
!opts.expectOnboarding &&
(await setupModal.isVisible().catch(() => false))
) {
await setupModal.getByTestId("modal-close").click();
await expect(setupModal).not.toBeVisible();
}
@@ -0,0 +1,363 @@
/**
* Agent Network focused-view spec.
*
* Exercises the account-driven focused view added for netbird.ai signups:
* the `agent_network_only` account setting hides the rest of the dashboard,
* an explicit `false` opts back out, and a pending netbird.ai signup applies
* the focused view optimistically and persists the setting via PUT /accounts.
*
* The test management backend does not know the setting, so GET /accounts is
* rewritten per-test to inject it (same interception approach as
* edition-gating.spec.ts). "only" mode is asserted through the Networks nav
* item, which toggles purely on the focused-view flag and does not depend on
* premium permission modules.
*/
import { test, expect, type Browser, type Page } from "@playwright/test";
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
// onboarding state onto the real account. A PUT to /accounts/{id} is captured
// and flips the injected setting to true, mirroring the server persisting the
// value so the optimistic view does not revert once the write settles.
function mockAccounts(
page: Page,
initial: AccountMock,
captured: { putBody?: any },
) {
let applied = initial.agentNetworkOnly;
page.route("**/api/accounts", async (route) => {
if (route.request().method() !== "GET") return route.continue();
const response = await route.fetch();
let body: any;
try {
body = await response.json();
} catch (e) {
return route.fulfill({ response });
}
if (Array.isArray(body) && body[0]) {
body[0].settings = { ...(body[0].settings ?? {}) };
if (applied === undefined) {
delete body[0].settings.agent_network_only;
} 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,
};
}
return route.fulfill({ response, json: body });
});
page.route("**/api/accounts/*", async (route) => {
if (route.request().method() !== "PUT") return route.continue();
try {
captured.putBody = route.request().postDataJSON();
} catch (e) {
captured.putBody = null;
}
applied = true;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ settings: { agent_network_only: true } }),
});
});
}
async function openWithAccount(
browser: Browser,
account: AccountMock,
opts: {
signupSource?: boolean;
agentNetworkConfig?: AgentNetworkConfig;
} = {},
): Promise<{
page: Page;
captured: { putBody?: any };
close: () => Promise<void>;
}> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
if (opts.signupSource) {
await context.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch (e) {}
},
[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);
await loginToApp(page, "owner");
return { page, captured, close: () => context.close() };
}
function navItem(page: Page, text: string) {
return page
.getByTestId("left-navigation-item")
.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"];
// Agent Network view routes that make up the focused surface.
const AGENT_VIEW_PATHS = [
"/agent-network/providers",
"/agent-network/policies",
"/agent-network/usage",
"/agent-network/configuration",
];
test.describe.serial("Agent Network focused view @agent-network", () => {
test("focused menu shows only Agent Network views and hides the regular sections", async ({
browser,
}) => {
const { page, close } = await openWithAccount(browser, {
agentNetworkOnly: true,
});
try {
// Peers always renders for an owner and is not part of the focused
// split, so it confirms the sidebar rendered before asserting absences.
await expect(navItem(page, "Peers")).toBeVisible();
// The Agent Network section is present and the regular sections are gone.
await expect(navItem(page, "Agent Network")).toBeVisible();
for (const label of FOCUS_HIDDEN_NAV) {
await expect(navItem(page, label)).toHaveCount(0);
}
} finally {
await close();
}
});
test("focused view keeps every Agent Network route reachable", async ({
browser,
}) => {
const { page, close } = await openWithAccount(browser, {
agentNetworkOnly: true,
});
try {
// Each Agent Network view renders instead of the route guard redirecting
// away; the Agent Network section stays in the sidebar throughout.
for (const path of AGENT_VIEW_PATHS) {
await navigateTo(page, path);
await expect(page).toHaveURL(new RegExp(path.replace(/\//g, "\\/")));
await expect(navItem(page, "Agent Network")).toBeVisible();
}
} finally {
await close();
}
});
test("explicit opt-out restores the regular menu and removes Agent Network", async ({
browser,
}) => {
const { page, close } = await openWithAccount(browser, {
agentNetworkOnly: false,
});
try {
// The sidebar rendered (Peers is always present for an owner)...
await expect(navItem(page, "Peers")).toBeVisible();
// ...and the Agent Network section is gone: the explicit opt-out wins
// and, without a deployment config flag, the section is not enabled.
await expect(navItem(page, "Agent Network")).toHaveCount(0);
} finally {
await close();
}
});
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,
}) => {
const { page, captured, close } = await openWithAccount(
browser,
{ agentNetworkOnly: undefined, signupFormPending: true },
{ signupSource: true },
);
try {
// Focused view is applied immediately, before the setting is persisted.
await expect(navItem(page, "Agent Network")).toBeVisible();
await expect(navItem(page, "Networks")).toHaveCount(0);
// 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);
} finally {
await close();
}
});
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,
}) => {
const { page, close } = await openWithAccount(browser, {
agentNetworkOnly: true,
});
try {
await navigateTo(page, "/settings?tab=clients");
await expect(page.getByTestId("agent-network-only")).toBeVisible();
} finally {
await close();
}
});
});
+109
View File
@@ -0,0 +1,109 @@
/**
* Validates that the dashboard surfaces the management API's request id in the
* error toast. Newer management servers set an `X-Request-Id` response header
* (see telemetry.RequestIDHeader). When present, the dashboard shows it in the
* error notification so it can be quoted in support tickets. Older servers omit
* the header, and the toast must render without a request id.
*
* Each scenario runs in its own browser context so the mocked `/networks`
* route and its resulting toast never bleed into another test through the
* worker-scoped page.
*/
import { test, expect, type Browser, type Page } from "@playwright/test";
import { loginToApp, navigateTo } from "../helpers/auth";
import * as path from "path";
const SCREENSHOTS_DIR = path.resolve(__dirname, "..", "screenshots");
const REQUEST_ID = "9m4e2mr0ui3e8a215n4g";
const NETWORKS_ENDPOINT = /\/api\/networks(\?|$)/;
const REQUEST_ID_TESTID = "notification-request-id";
const TITLE_TESTID = "notification-title";
// failNetworks mocks the networks list GET with a 500, optionally attaching the
// X-Request-Id header to emulate a newer management server. The dashboard talks
// to the management API cross-origin, so the mock must mirror a CORS-enabled
// server: the browser only lets JS read a non-safelisted response header when it
// is listed in Access-Control-Expose-Headers. This is the same requirement the
// real management server must satisfy for the feature to work in production.
async function failNetworks(page: Page, opts: { withRequestId: boolean }) {
await page.route(NETWORKS_ENDPOINT, async (route) => {
if (route.request().method() !== "GET") return route.continue();
const origin = route.request().headers()["origin"] || "*";
const headers: Record<string, string> = {
"content-type": "application/json",
"access-control-allow-origin": origin,
"access-control-expose-headers": "X-Request-Id",
};
if (opts.withRequestId) headers["X-Request-Id"] = REQUEST_ID;
await route.fulfill({
status: 500,
headers,
body: JSON.stringify({ code: 500, message: "internal server error" }),
});
});
}
async function openOwner(
browser: Browser,
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
const page = await context.newPage();
return { page, close: () => context.close() };
}
test.describe.serial("API error request id @error-handling", () => {
test("shows the X-Request-Id from a failed request in the error toast", async ({
browser,
}) => {
const { page, close } = await openOwner(browser);
try {
await failNetworks(page, { withRequestId: true });
await loginToApp(page, "owner");
await navigateTo(page, "/networks");
const requestIdLine = page.getByTestId(REQUEST_ID_TESTID);
await expect(requestIdLine).toBeVisible();
await expect(requestIdLine).toContainText(REQUEST_ID);
await expect(page.getByTestId(TITLE_TESTID).first()).toContainText(
"Request failed with status code 500",
);
const toast = page.locator("[data-toast-notification]").first();
await toast.screenshot({
path: path.join(SCREENSHOTS_DIR, "error-toast-with-request-id.png"),
});
await page.screenshot({
path: path.join(SCREENSHOTS_DIR, "networks-page-error-request-id.png"),
});
} finally {
await close();
}
});
test("omits the request id when the server does not send the header", async ({
browser,
}) => {
const { page, close } = await openOwner(browser);
try {
await failNetworks(page, { withRequestId: false });
await loginToApp(page, "owner");
await navigateTo(page, "/networks");
// The generic error toast still appears for older servers...
await expect(page.getByTestId(TITLE_TESTID).first()).toContainText(
"Request failed with status code 500",
);
// ...but no request id line is rendered.
await expect(page.getByTestId(REQUEST_ID_TESTID)).toHaveCount(0);
const toast = page.locator("[data-toast-notification]").first();
await toast.screenshot({
path: path.join(SCREENSHOTS_DIR, "error-toast-without-request-id.png"),
});
} finally {
await close();
}
});
});
+169
View File
@@ -0,0 +1,169 @@
/**
* Onboarding form selection spec.
*
* Validates which onboarding form is shown first and which one persists,
* across signup-source and account states — the recurring "regular form
* flashes before the Agent Network form" bug. Onboarding is disabled in the
* test build by default; each test opts in via the `netbird-test-onboarding`
* localStorage flag (see testOnboardingEnabled in src/utils/netbird.ts), and
* GET /accounts is rewritten to drive the account's onboarding + settings
* state (same interception approach as edition-gating.spec.ts).
*/
import { test, expect, type Browser, type Page } from "@playwright/test";
import { loginToApp } from "../helpers/auth";
const SIGNUP_SOURCE_KEY = "netbird-signup-source";
const AGENT_NETWORK_SOURCE = "netbird.ai";
const AGENT_FORM = "agent-network-onboarding";
const REGULAR_FORM = "regular-onboarding";
type AccountState = {
signupFormPending?: boolean;
onboardingFlowPending?: boolean;
agentNetworkOnly?: boolean;
};
// mockAccounts rewrites GET /accounts to drive the onboarding + focused-view
// state, optionally delaying the response to emulate a slow backend. A PUT to
// /accounts/{id} flips agent_network_only to true afterwards, mirroring the
// server persisting the netbird.ai signup mark.
function mockAccounts(page: Page, state: AccountState, delayMs: number) {
let agentNetworkOnly = state.agentNetworkOnly;
page.route("**/api/accounts", async (route) => {
if (route.request().method() !== "GET") return route.continue();
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
const response = await route.fetch();
let body: any;
try {
body = await response.json();
} catch (e) {
return route.fulfill({ response });
}
if (Array.isArray(body) && body[0]) {
body[0].onboarding = {
...(body[0].onboarding ?? {}),
signup_form_pending: !!state.signupFormPending,
onboarding_flow_pending: !!state.onboardingFlowPending,
};
body[0].settings = { ...(body[0].settings ?? {}) };
if (agentNetworkOnly === undefined) {
delete body[0].settings.agent_network_only;
} else {
body[0].settings.agent_network_only = agentNetworkOnly;
}
}
return route.fulfill({ response, json: body });
});
page.route("**/api/accounts/*", async (route) => {
if (route.request().method() !== "PUT") return route.continue();
agentNetworkOnly = true;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ settings: { agent_network_only: true } }),
});
});
}
async function openOnboarding(
browser: Browser,
opts: { source?: boolean; account: AccountState; delayMs?: number },
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
await context.addInitScript(
([sourceKey, sourceValue, withSource]) => {
try {
window.localStorage.setItem("netbird-test-onboarding", "true");
if (withSource) {
window.localStorage.setItem(
sourceKey as string,
sourceValue as string,
);
}
} catch (e) {}
},
[SIGNUP_SOURCE_KEY, AGENT_NETWORK_SOURCE, !!opts.source] as const,
);
const page = await context.newPage();
mockAccounts(page, opts.account, opts.delayMs ?? 0);
await loginToApp(page, "owner", { expectOnboarding: true });
return { page, close: () => context.close() };
}
test.describe.serial("Onboarding form selection @onboarding", () => {
test("netbird.ai signup shows the Agent Network form and never the regular one", async ({
browser,
}) => {
const { page, close } = await openOnboarding(browser, {
source: true,
account: { signupFormPending: true, onboardingFlowPending: true },
});
try {
await expect(page.getByTestId(AGENT_FORM)).toBeVisible();
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
} finally {
await close();
}
});
test("a plain new cloud account shows the regular onboarding form", async ({
browser,
}) => {
const { page, close } = await openOnboarding(browser, {
source: false,
account: { signupFormPending: true, onboardingFlowPending: true },
});
try {
await expect(page.getByTestId(REGULAR_FORM)).toBeVisible();
await expect(page.getByTestId(AGENT_FORM)).toHaveCount(0);
} finally {
await close();
}
});
test("an account already marked agent_network_only shows the Agent Network form", async ({
browser,
}) => {
const { page, close } = await openOnboarding(browser, {
source: false,
account: { onboardingFlowPending: true, agentNetworkOnly: true },
});
try {
await expect(page.getByTestId(AGENT_FORM)).toBeVisible();
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
} finally {
await close();
}
});
test("a slow backend never flashes the regular form for a netbird.ai signup", async ({
browser,
}) => {
const { page, close } = await openOnboarding(browser, {
source: true,
account: { signupFormPending: true, onboardingFlowPending: true },
delayMs: 1500,
});
try {
// While /accounts is still loading, no onboarding form should have been
// committed to the wrong flow.
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
// Once it resolves, the Agent Network form is the one that appears...
await expect(page.getByTestId(AGENT_FORM)).toBeVisible();
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
// ...and it persists after the setting is applied (no flip/close).
await page.waitForTimeout(1500);
await expect(page.getByTestId(AGENT_FORM)).toBeVisible();
await expect(page.getByTestId(REGULAR_FORM)).toHaveCount(0);
} finally {
await close();
}
});
});
+2
View File
@@ -137,6 +137,7 @@ async function revokeSetupKey(
);
await page.getByTestId("confirmation.confirm").click();
await responsePromise;
await expect(page.getByRole("dialog")).not.toBeVisible();
await expect(
page
.locator("tr")
@@ -158,4 +159,5 @@ async function deleteSetupKey(
await page.getByTestId("delete-setup-key").click({ force: true });
await page.getByTestId("confirmation.confirm").click();
await expect(page.locator("tr").filter({ hasText: name })).not.toBeVisible();
await expect(page.getByRole("dialog")).not.toBeVisible();
}
@@ -1,25 +1,33 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { VerticalTabs } from "@components/VerticalTabs";
import * as Tabs from "@radix-ui/react-tabs";
import { Gauge, ScrollText } from "lucide-react";
import { ExternalLinkIcon, Gauge, ScrollText, ServerIcon } from "lucide-react";
import { useSearchParams } from "next/navigation";
import React, { Suspense, useEffect, useState } from "react";
import React, { lazy, Suspense, useEffect, useState } from "react";
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
import GroupsProvider from "@/contexts/GroupsProvider";
import PeersProvider from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { REVERSE_PROXY_CLUSTERS_DOCS_LINK } from "@/interfaces/ReverseProxy";
import PageContainer from "@/layouts/PageContainer";
import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode";
import AgentAccountControlsCard from "@/modules/agent-network/AgentAccountControlsCard";
import AgentBudgetRulesTable from "@/modules/agent-network/AgentBudgetRulesTable";
import AIProvidersProvider from "@/modules/agent-network/AIProvidersProvider";
const ClustersTable = lazy(
() => import("@/modules/reverse-proxy/clusters/ClustersTable"),
);
const TAB_BUDGET_SETTINGS = "budget-settings";
const TAB_LOG_SETTINGS = "log-settings";
const TAB_CLUSTERS = "clusters";
// AgentNetworkConfigurationPage holds the configuration surfaces extracted from
// Usage & Logs — global limits and log-collection controls — using the same
@@ -27,6 +35,7 @@ const TAB_LOG_SETTINGS = "log-settings";
// Settings page so it reads as "settings for the Agent Network".
export default function AgentNetworkConfigurationPage() {
const { permission } = usePermissions();
const { only: agentNetworkOnly } = useAgentNetworkMode();
const queryParams = useSearchParams();
const queryTab = queryParams.get("tab");
const [tab, setTab] = useState(queryTab ?? TAB_BUDGET_SETTINGS);
@@ -47,6 +56,10 @@ export default function AgentNetworkConfigurationPage() {
<ScrollText size={14} />
Log Collection
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value={TAB_CLUSTERS}>
<ServerIcon size={14} />
Clusters
</VerticalTabs.Trigger>
</VerticalTabs.List>
<RestrictedAccess
page={"Configuration"}
@@ -82,6 +95,27 @@ export default function AgentNetworkConfigurationPage() {
<AgentAccountControlsCard />
</Suspense>
</Tabs.Content>
<Tabs.Content value={TAB_CLUSTERS} className={"w-full"}>
<ConfigTabHeader
label={"Clusters"}
href={"/agent-network/configuration?tab=clusters"}
>
{agentNetworkOnly
? "Proxy clusters route your agents' traffic to AI providers and run on your own infrastructure. Add multiple clusters to scale your environment."
: "Proxy clusters route inbound traffic to your services. Shared clusters are run by the platform; account clusters (self-hosted) run on your own infrastructure."}{" "}
<InlineLink
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</ConfigTabHeader>
<Suspense fallback={<SkeletonTable />}>
<ClustersTable />
</Suspense>
</Tabs.Content>
</div>
</AIProvidersProvider>
</PeersProvider>
+14 -7
View File
@@ -1,19 +1,26 @@
"use client";
import { isAgentNetworkEnabled } from "@utils/netbird";
import { notFound } from "next/navigation";
import * as React from "react";
import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode";
// Gates the entire Agent Network route tree behind the NETBIRD_AGENT_NETWORK
// flag. When disabled, these routes don't exist as far as the user is
// concerned — the dashboard behaves exactly as it did without the feature.
// flag or the account-level agent_network_only setting. When disabled, these
// routes don't exist as far as the user is concerned — the dashboard behaves
// exactly as it did without the feature. Rendering waits for the account to
// load so accounts enabled via settings don't get a redirect flash.
export default function AgentNetworkLayout({
children,
}: {
children: React.ReactNode;
}) {
if (!isAgentNetworkEnabled()) {
notFound();
const { enabled, loading } = useAgentNetworkMode();
if (enabled) {
return <>{children}</>;
}
return <>{children}</>;
}
if (loading) {
return null;
}
notFound();
}
@@ -193,7 +193,7 @@ export default function AgentNetworkProvidersPage() {
Anthropic through one keyless endpoint, accessible only via
NetBird’s tunnel.
<InlineLink
href={"https://anet.demo.netbird.io/"}
href={"https://docs.netbird.io/agent-network/providers"}
target={"_blank"}
>
Learn more
@@ -40,6 +40,11 @@ export default function UsageAndLogsPage() {
const initialTab = VALID_TABS.has(queryTab) ? queryTab : TAB_USAGE;
const [tab, setTab] = useState(initialTab);
// Access-log view mode: flat per-request rows, or grouped by provider session.
// Each mode hits its own endpoint and the provider is remounted on toggle
// (keyed below) so the two response shapes never cross.
const [groupBySession, setGroupBySession] = useState(false);
// Default the access-log view to the last 14 days. Computed once on mount so
// the window is stable across re-renders; resetting filters returns here.
const defaultAccessLogFilters = useMemo(
@@ -120,11 +125,19 @@ export default function UsageAndLogsPage() {
<TabsContent value={TAB_ACCESS_LOGS} className={"pb-8"}>
<Suspense fallback={<SkeletonTable />}>
<ServerPaginationProvider
url={"/agent-network/access-logs"}
key={groupBySession ? "sessions" : "flat"}
url={
groupBySession
? "/agent-network/access-log-sessions"
: "/agent-network/access-logs"
}
defaultPageSize={25}
defaultFilters={defaultAccessLogFilters}
>
<AgentAccessLogTable />
<AgentAccessLogTable
grouped={groupBySession}
onGroupedChange={setGroupBySession}
/>
</ServerPaginationProvider>
</Suspense>
</TabsContent>
+7 -5
View File
@@ -12,7 +12,6 @@ import SquareIcon from "@components/SquareIcon";
import GetStartedTest from "@components/ui/GetStartedTest";
import { SmallBadge } from "@components/ui/SmallBadge";
import useFetchApi from "@utils/api";
import { isAgentNetworkEnabled, isAgentNetworkOnly } from "@utils/netbird";
import {
Background,
Edge,
@@ -54,6 +53,7 @@ import AIProvidersProvider, {
useAIProviders,
} from "@/modules/agent-network/AIProvidersProvider";
import { AIProviderId } from "@/modules/agent-network/data/mockData";
import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode";
import { FlowSelector, FlowView } from "@/modules/control-center/FlowSelector";
import { NetworkRoutingPeerCount } from "@/modules/control-center/NetworkRoutingPeerCount";
import { ControlCenterCurrentUserBadge } from "@/modules/control-center/user/ControlCenterCurrentUserBadge";
@@ -90,6 +90,8 @@ function ControlCenterView() {
const [layoutInitialized, setLayoutInitialized] = useState(false);
const [forceLayoutChange, setForceLayoutChange] = useState(false);
const { loggedInUser } = useLoggedInUser();
const { only: agentNetworkOnly, enabled: agentNetworkEnabled } =
useAgentNetworkMode();
const queryParams = useSearchParams();
const queryTab = queryParams.get("tab");
@@ -125,13 +127,13 @@ function ControlCenterView() {
"/agent-network/providers",
true,
true,
isAgentNetworkEnabled(),
agentNetworkEnabled,
);
const { data: agentPolicies } = useFetchApi<APIPolicy[]>(
"/agent-network/policies",
true,
true,
isAgentNetworkEnabled(),
agentNetworkEnabled,
);
// providerById lets the overlay look up a Provider's display payload
@@ -1995,7 +1997,7 @@ function ControlCenterView() {
{/* Networks is dropped as a top-level pivot in the
agent-network repackaging — keep the dropdown + per-network
chrome for everyone else so flag-off behaviour is unchanged. */}
{!isAgentNetworkOnly() && currentView === "networks" && (
{!agentNetworkOnly && currentView === "networks" && (
<div className={"w-64"}>
<SelectDropdown
variant={"secondary"}
@@ -2011,7 +2013,7 @@ function ControlCenterView() {
</div>
)}
{!isAgentNetworkOnly() && selectedNetwork && currentNetwork && (
{!agentNetworkOnly && selectedNetwork && currentNetwork && (
<NetworkRoutingPeerCount network={currentNetwork} />
)}
</div>
+24 -335
View File
@@ -4,338 +4,39 @@ import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import useFetchApi from "@utils/api";
import { usePortalElement } from "@hooks/usePortalElement";
import dayjs from "dayjs";
import { ArrowLeftRightIcon, ExternalLinkIcon } from "lucide-react";
import { usePathname, useSearchParams } from "next/navigation";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { DateRange } from "react-day-picker";
import React, { useMemo } from "react";
import ActivityIcon from "@/assets/icons/ActivityIcon";
import { useIsFeatureLocked } from "@/cloud/cloud-hooks/useIsFeatureLocked";
import { TrafficEvent } from "@/cloud/traffic-events/interfaces/TrafficEvent";
import { TRAFFIC_EVENTS_DOC_LINK } from "@/cloud/traffic-events/TrafficEventSetting";
import TrafficEventsTable from "@/cloud/traffic-events/TrafficEventsTable";
import PeersProvider from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useUsers } from "@/contexts/UsersProvider";
import { Pagination } from "@/interfaces/Pagination";
import ServerPaginationProvider from "@/contexts/ServerPaginationProvider";
import PageContainer from "@/layouts/PageContainer";
import { useAccount } from "@/modules/account/useAccount";
import { LockedFeatureInfoCard } from "@/modules/billing/locked-feature/LockedFeatureInfoCard";
import { LockedFeatureOverlay } from "@/modules/billing/locked-feature/LockedFeatureOverlay";
import { EventStreamingCard } from "@/modules/integrations/event-streaming/EventStreamingCard";
const DEFAULT_PAGE = "1";
const DEFAULT_PAGE_SIZE = "10";
export default function NetworkTrafficPage() {
const account = useAccount();
const { permission } = usePermissions();
const pathname = usePathname();
const searchParams = useSearchParams();
const { users } = useUsers();
const currentPage = searchParams.get("page") || DEFAULT_PAGE;
const currentPageSize = searchParams.get("page_size") || DEFAULT_PAGE_SIZE;
const searchTerm = searchParams.get("search") || "";
const dateFrom = searchParams.get("start_date");
const dateTo = searchParams.get("end_date");
const userId = searchParams.get("user_id") || "";
const typeFilter = searchParams.getAll("type") || [];
const connectionTypeFilter = searchParams.get("connection_type") || "";
const directionFilter = searchParams.getAll("direction") || [];
const protocolFilter = searchParams.getAll("protocol") || [];
const formattedDateFrom = dateFrom ? encodeURIComponent(dateFrom) : "";
const formattedDateTo = dateTo ? encodeURIComponent(dateTo) : "";
const formattedUserId = userId ? encodeURIComponent(userId) : "";
const [dateRange, setDateRange] = useState<DateRange | undefined>(() => {
if (dateFrom || dateTo) {
return {
from: dateFrom ? dayjs(dateFrom).toDate() : undefined,
to: dateTo ? dayjs(dateTo).toDate() : undefined,
};
}
return undefined;
});
useEffect(() => {
localStorage.removeItem(`netbird-table-pagination${pathname}`);
localStorage.removeItem(`netbird-table-range${pathname}`);
localStorage.removeItem(`netbird-table-search${pathname}`);
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.includes(pathname)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => {
localStorage.removeItem(key);
});
}, [pathname]);
const baseUrl = `/events/network-traffic`;
const [apiUrl, setApiUrl] = useState(() => {
let url = `${baseUrl}?page=${currentPage}&page_size=${currentPageSize}`;
if (searchTerm) {
url += `&search=${encodeURIComponent(searchTerm)}`;
}
if (formattedDateFrom) {
url += `&start_date=${formattedDateFrom}`;
}
if (formattedDateTo) {
url += `&end_date=${formattedDateTo}`;
}
if (typeFilter) {
typeFilter.forEach((t) => {
url += `&type=${encodeURIComponent(t)}`;
});
}
if (connectionTypeFilter) {
url += `&connection_type=${encodeURIComponent(connectionTypeFilter)}`;
}
if (directionFilter) {
protocolFilter.forEach((d) => {
url += `&direction=${encodeURIComponent(d)}`;
});
}
if (formattedUserId) {
url += `&user_id=${formattedUserId}`;
}
if (protocolFilter) {
protocolFilter.forEach((protocol) => {
url += `&protocol=${encodeURIComponent(protocol)}`;
});
}
return url;
});
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const isTrafficEventsLocked = useIsFeatureLocked("TRAFFIC_EVENTS");
const {
data: events,
isLoading,
mutate,
} = useFetchApi<Pagination<TrafficEvent[]>>(
apiUrl,
false,
true,
!isTrafficEventsLocked,
);
// Fetch is suppressed while the feature lock resolves, refetch once unlocked.
useEffect(() => {
if (!isTrafficEventsLocked) {
mutate();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isTrafficEventsLocked]);
const updateURL = useCallback(
(newParams: Record<string, string | string[]>) => {
const params = new URLSearchParams(searchParams.toString());
const isFilterReset = Object.values(newParams).every(
(value) => value === "",
);
params.set("page", currentPage);
params.set("page_size", currentPageSize);
if (isFilterReset) {
params.delete("search");
params.delete("start_date");
params.delete("end_date");
params.delete("type");
params.delete("connection_type");
params.delete("direction");
params.delete("protocol");
params.delete("user_id");
params.set("page", "1");
params.set("page_size", "10");
} else {
Object.entries(newParams).forEach(([key, value]) => {
if (value === "") {
if (key === "page_size" || key === "page") return;
params.delete(key);
} else if (Array.isArray(value)) {
value.forEach((v, i) => {
if (i === 0) {
params.set(key, v);
return;
}
params.append(key, v);
});
} else {
params.set(key, value);
}
});
}
setApiUrl(`${baseUrl}?${params.toString()}`);
if (typeof window !== "undefined") {
window.history.replaceState(
null,
"",
`${pathname}?${params.toString()}`,
);
}
},
[baseUrl, pathname, searchParams],
);
const handlePaginationChange = useCallback(
({ pageIndex, pageSize }: { pageIndex: number; pageSize: number }) => {
updateURL({
page: String(pageIndex + 1),
page_size: String(pageSize),
});
},
[updateURL],
);
const handleGlobalFilterChange = useCallback(
(value: string) => {
if (value === "" && currentPage !== "1") return;
updateURL({
search: value,
page: "1",
});
},
[updateURL],
);
const handleDateFilterChange = useCallback(
(from?: Date, to?: Date) => {
const params: Record<string, string> = {};
if (from) {
params.start_date = dayjs(from).toISOString();
} else {
params.start_date = "";
}
if (to) {
params.end_date = dayjs(to).toISOString();
} else {
params.end_date = "";
}
params.page = "1";
updateURL(params);
},
[updateURL],
);
const handleFilterChange = useCallback(
(filters: {
type?: string[];
direction?: string[];
protocol?: string[];
}) => {
const params: Record<string, string | string[]> = {};
if (filters.type && filters.type.length > 0) {
params.type = filters.type;
} else {
params.type = "";
}
if (filters.direction && filters.direction.length > 0) {
params.direction = filters.direction;
} else {
params.direction = "";
}
if (filters.protocol && filters.protocol.length > 0) {
params.protocol = filters.protocol;
} else {
params.protocol = "";
}
params.page = "1";
updateURL(params);
},
[updateURL],
);
const handleConnectionTypeFilterChange = useCallback(
(value: string) => {
updateURL({
connection_type: value,
page: "1",
});
},
[updateURL],
);
const handleUserFilterChange = useCallback(
(selectedUserId: string) => {
updateURL({
user_id: selectedUserId || "",
page: "1",
});
},
[updateURL],
);
const handleResetAllFilters = useCallback(() => {
const params = new URLSearchParams();
params.set("page", "1");
params.set("page_size", "10");
const newUrl = `${baseUrl}?${params.toString()}`;
setApiUrl(newUrl);
if (typeof window !== "undefined") {
window.history.replaceState(null, "", `${pathname}?${params.toString()}`);
}
}, [currentPageSize, baseUrl, pathname]);
useEffect(() => {
if (dateFrom || dateTo) {
try {
const fromDate = dateFrom ? dayjs(dateFrom).toDate() : undefined;
const toDate = dateTo ? dayjs(dateTo).toDate() : undefined;
const newDateRange = {
from: fromDate,
to: toDate,
};
setDateRange(newDateRange);
} catch (error) {}
} else if (dateRange) {
setDateRange(undefined);
}
}, [dateFrom, dateTo]);
const pagination = {
pageIndex: parseInt(currentPage, 10) - 1,
pageSize: parseInt(currentPageSize, 10),
};
const isEnabled = !!account?.settings?.extra?.network_traffic_logs_enabled;
const tableFilters = {
type: typeFilter,
direction: directionFilter,
protocol: protocolFilter,
};
const trafficEvents = useMemo(() => {
// `data` may resolve to a non-array (e.g. an error body) on locked /
// self-hosted deployments; guard so `.map` never throws.
if (!Array.isArray(events?.data)) return undefined;
return events.data.map((event) => ({
...event,
id: event.flow_id,
}));
}, [events]);
const defaultFilters = useMemo(
() => ({
start_date: dayjs().subtract(7, "day").startOf("day").toISOString(),
end_date: dayjs().endOf("day").toISOString(),
}),
[],
);
return (
<PageContainer>
@@ -353,7 +54,7 @@ export default function NetworkTrafficPage() {
/>
</Breadcrumbs>
<h1>{`${events?.total_records ?? 0}`} Traffic Events</h1>
<h1 ref={headingRef}>Traffic Events</h1>
<Paragraph>
Traffic events is an experimental feature. Functionality and behavior
@@ -383,29 +84,17 @@ export default function NetworkTrafficPage() {
<LockedFeatureOverlay feature={"TRAFFIC_EVENTS"}>
<EventStreamingCard />
<PeersProvider>
<TrafficEventsTable
events={trafficEvents}
isLoading={isLoading}
isSettingEnabled={isEnabled}
totalRecords={events?.total_records}
totalPages={events?.total_pages}
onPaginationChange={handlePaginationChange}
pagination={pagination}
onGlobalFilterChange={handleGlobalFilterChange}
globalFilter={searchTerm}
onDateFilterChange={handleDateFilterChange}
dateFrom={dateFrom || ""}
dateTo={dateTo || ""}
apiUrl={apiUrl}
filters={tableFilters}
onFilterChange={handleFilterChange}
users={users}
userId={userId}
onUserFilterChange={handleUserFilterChange}
onResetAllFilters={handleResetAllFilters}
connectionTypeFilter={connectionTypeFilter}
onConnectionTypeFilterChange={handleConnectionTypeFilterChange}
/>
<ServerPaginationProvider
url={"/events/network-traffic"}
defaultPageSize={10}
defaultFilters={defaultFilters}
enabled={!isTrafficEventsLocked}
>
<TrafficEventsTable
headingTarget={portalTarget}
isSettingEnabled={isEnabled}
/>
</ServerPaginationProvider>
</PeersProvider>
</LockedFeatureOverlay>
</RestrictedAccess>
@@ -40,9 +40,9 @@ export default function ReverseProxyClustersPage() {
</Breadcrumbs>
<h1 ref={headingRef}>Clusters</h1>
<Paragraph>
Proxy clusters that route inbound traffic to your services. Shared
clusters are deployed at the server level; account clusters are
self-hosted on your own infrastructure.{" "}
Proxy clusters route inbound traffic to your services. Shared clusters
are run by the platform; account clusters (self-hosted) run on your
own infrastructure.{" "}
<InlineLink href={REVERSE_PROXY_CLUSTERS_DOCS_LINK} target={"_blank"}>
Learn more
<ExternalLinkIcon size={12} />
+7
View File
@@ -4,6 +4,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { VerticalTabs } from "@components/VerticalTabs";
import {
AlertOctagonIcon,
ChartNoAxesCombined,
FingerprintIcon,
FolderGit2Icon,
KeyRound,
@@ -27,6 +28,7 @@ import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab";
import PermissionsTab from "@/modules/settings/PermissionsTab";
import SetupKeysTab from "@/modules/settings/SetupKeysTab";
import GroupsSettings from "@/modules/settings/GroupsSettings";
import MetricsTab from "@/modules/settings/MetricsTab";
import {
CloudSettingsTabContent,
CloudSettingsTabTrigger,
@@ -92,6 +94,10 @@ export default function NetBirdSettings() {
<MonitorSmartphoneIcon size={14} />
Clients
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value="metrics">
<ChartNoAxesCombined size={14} />
Metrics
</VerticalTabs.Trigger>
</>
)}
<CloudSettingsTabTrigger />
@@ -110,6 +116,7 @@ export default function NetBirdSettings() {
{account && <GroupsSettings account={account} />}
{account && <NetworkSettingsTab account={account} />}
{account && <ClientSettingsTab account={account} />}
{account && <MetricsTab account={account} />}
{account && <DangerZoneTab account={account} />}
<CloudSettingsTabContent />
</div>
Loaded 20 of 79 files, more files were not shown because too many files have changed in this diff. Show more