mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
377d239a01 | ||
|
|
a266fff89d | ||
|
|
8ed69f21c5 | ||
|
|
58d1035b16 | ||
|
|
991234785c | ||
|
|
f2ad889fdc | ||
|
|
1f219c4ffc | ||
|
|
87b3a08adc | ||
|
|
cfd0037cef | ||
|
|
ac7d578bd7 | ||
|
|
8d41de995c |
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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=""
|
||||
|
||||
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
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,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();
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -67,24 +67,55 @@ function RDPSession({ peer }: Props) {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset the RDP session state but keep the NetBird client connected,
|
||||
* so a retry does not pay the full engine reconnect.
|
||||
*/
|
||||
const reset = useCallback(async () => {
|
||||
setCredentials(null);
|
||||
connected.current = false;
|
||||
setCredentialsModal(true);
|
||||
rdp.session?.disconnect();
|
||||
await client.disconnect();
|
||||
}, [client, rdp]);
|
||||
}, [rdp]);
|
||||
|
||||
// Port the temporary access peer was created for; access is scoped to it.
|
||||
const connectedPort = useRef<number | null>(null);
|
||||
|
||||
/**
|
||||
* Establishes a connection to the peer
|
||||
*/
|
||||
const connect = async (rdpCredentials: RDPCredentials) => {
|
||||
if (!peer?.id) return;
|
||||
if (client.status === NetBirdStatus.DISCONNECTED) {
|
||||
|
||||
let status = client.status;
|
||||
|
||||
// The temporary access is scoped to a single port, so a port change
|
||||
// requires a fresh temporary peer.
|
||||
if (
|
||||
status === NetBirdStatus.CONNECTED &&
|
||||
connectedPort.current !== null &&
|
||||
connectedPort.current !== rdpCredentials.port
|
||||
) {
|
||||
try {
|
||||
await client.disconnect();
|
||||
connectedPort.current = null;
|
||||
status = NetBirdStatus.DISCONNECTED;
|
||||
} catch (error) {
|
||||
sendErrorNotification(
|
||||
"NetBird Connection Error",
|
||||
(error as Error).message,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setCredentials(rdpCredentials);
|
||||
|
||||
if (status === NetBirdStatus.DISCONNECTED) {
|
||||
try {
|
||||
setCredentials(rdpCredentials);
|
||||
setIsNetBirdConnecting(true);
|
||||
await client.connectTemporary(peer.id, [`tcp/${rdpCredentials.port}`]);
|
||||
connectedPort.current = rdpCredentials.port;
|
||||
setIsNetBirdConnecting(false);
|
||||
} catch (error) {
|
||||
sendErrorNotification(
|
||||
|
||||
@@ -13,7 +13,7 @@ import Skeleton from "react-loading-skeleton";
|
||||
import AWSMarketplaceLogo from "@/assets/integrations/aws-marketplace.svg";
|
||||
import { useBilling } from "@/contexts/BillingProvider";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import { Plan } from "@/interfaces/Plan";
|
||||
import { Currency, Plan } from "@/interfaces/Plan";
|
||||
import { PlanTier } from "@/interfaces/Subscription";
|
||||
import { PlanCard, PlanLoadingSkeleton } from "@/modules/billing/PlanCard";
|
||||
|
||||
@@ -27,7 +27,6 @@ export const AWSChoosePlan = ({ onSuccess }: Props) => {
|
||||
plans,
|
||||
isLoading,
|
||||
subscription,
|
||||
currency,
|
||||
changeSubscription,
|
||||
isTrial,
|
||||
subscribe,
|
||||
@@ -178,7 +177,7 @@ export const AWSChoosePlan = ({ onSuccess }: Props) => {
|
||||
upgrade: "Subscribe to",
|
||||
downgrade: "Downgrade to",
|
||||
}}
|
||||
currency={currency}
|
||||
currency={Currency.USD}
|
||||
isSubscribing={isSubscribing}
|
||||
onClick={() => subscribeToPlan(plan)}
|
||||
key={plan.name}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { notify } from "@components/Notification";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import loadConfig from "@utils/config";
|
||||
import { isNetBirdCloud } from "@utils/netbird";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { Hubspot, submitHubspotForm } from "@/cloud/analytics/Hubspot";
|
||||
import { AWSChoosePlan } from "@/cloud/aws/AWSChoosePlan";
|
||||
@@ -11,8 +12,14 @@ import { useDomainCategory } from "@/cloud/cloud-hooks/useDomainCategory";
|
||||
import HowDidYouHearAboutUs from "@/cloud/survey/HowDidYouHearAboutUs";
|
||||
import { useAnalytics } from "@/contexts/AnalyticsProvider";
|
||||
import { useBilling } from "@/contexts/BillingProvider";
|
||||
import {
|
||||
AGENT_NETWORK_SIGNUP_SOURCE,
|
||||
SIGNUP_SOURCE_LOCAL_STORAGE_KEY,
|
||||
} from "@/hooks/useSignupSource";
|
||||
import type { Account } from "@/interfaces/Account";
|
||||
import type { Group } from "@/interfaces/Group";
|
||||
import { PlanTier } from "@/interfaces/Subscription";
|
||||
import { useAccount } from "@/modules/account/useAccount";
|
||||
import { OnboardingProvider } from "@/modules/onboarding/OnboardingProvider";
|
||||
|
||||
export const NetBirdCloudProvider = () => {
|
||||
@@ -25,6 +32,9 @@ export const NetBirdCloudProvider = () => {
|
||||
"/integrations/billing/aws/marketplace/enrich",
|
||||
true,
|
||||
).post;
|
||||
const account = useAccount();
|
||||
const accountRequest = useApiCall<Account>("/accounts", true).put;
|
||||
const signupSourceApplied = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -41,6 +51,64 @@ export const NetBirdCloudProvider = () => {
|
||||
} catch (e) {}
|
||||
}, []);
|
||||
|
||||
// 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;
|
||||
|
||||
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: isNewAccount
|
||||
? "Agent Network focused view enabled for your account."
|
||||
: "Agent Network enabled for your account.",
|
||||
promise: accountRequest(
|
||||
{ id: account.id, settings },
|
||||
"/" + account.id,
|
||||
).then(async () => {
|
||||
// Revalidate before clearing the source key so the persisted
|
||||
// setting is in cache first. Clearing it earlier would leave a
|
||||
// window where neither the source-pending optimism nor the stored
|
||||
// setting holds, briefly flipping the focused view off and closing
|
||||
// the onboarding form as the toast appears.
|
||||
await mutate("/accounts");
|
||||
localStorage.removeItem(SIGNUP_SOURCE_LOCAL_STORAGE_KEY);
|
||||
}),
|
||||
loadingMessage: isNewAccount
|
||||
? "Enabling Agent Network focused view..."
|
||||
: "Enabling Agent Network...",
|
||||
});
|
||||
} catch (e) {}
|
||||
}, [account]);
|
||||
|
||||
const hasFreeOrTrialAWSPlan =
|
||||
subscription?.plan_tier === PlanTier.FREE ||
|
||||
subscription?.plan_tier === PlanTier.TRIAL;
|
||||
|
||||
@@ -7,12 +7,7 @@ import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
|
||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||
import { ColumnDef, SortingState, Table } from "@tanstack/react-table";
|
||||
import {
|
||||
CreditCardIcon,
|
||||
HelpCircle,
|
||||
PlusCircle,
|
||||
ReceiptTextIcon,
|
||||
} from "lucide-react";
|
||||
import { HelpCircle, PlusCircle, ReceiptTextIcon } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import React, { Fragment } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
@@ -268,24 +263,3 @@ const AddTenantButton = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const TotalCost = () => {
|
||||
return (
|
||||
<div className={"flex items-center gap-3"}>
|
||||
<div
|
||||
className={
|
||||
"h-10 w-10 rounded-full bg-nb-gray-910 flex items-center justify-center"
|
||||
}
|
||||
>
|
||||
<CreditCardIcon size={16} className={"text-nb-gray-200"} />
|
||||
</div>
|
||||
<div className={"flex items-start justify-center text-left flex-col"}>
|
||||
<span className={"text-xs text-nb-gray-300 font-medium block"}>
|
||||
Total Cost / Month
|
||||
</span>
|
||||
<span className={"font-semibold text-base relative text-nb-gray-200"}>
|
||||
$5,260
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { useTenantPlan } from "@/cloud/msp/hooks/useTenantPlan";
|
||||
import { Tenant, TenantStatus } from "@/cloud/msp/interfaces/Tenant";
|
||||
import { useBilling } from "@/contexts/BillingProvider";
|
||||
import { Currency } from "@/interfaces/Plan";
|
||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
|
||||
@@ -12,8 +10,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export const TenantPlanCostCell = ({ tenant }: Props) => {
|
||||
const { calculateEstimatedPrice, currency } = useBilling();
|
||||
const { isLoading, currentPlan, stats } = useTenantPlan({
|
||||
const { isLoading, currency, estimatedPrice } = useTenantPlan({
|
||||
tenant,
|
||||
});
|
||||
|
||||
@@ -21,11 +18,6 @@ export const TenantPlanCostCell = ({ tenant }: Props) => {
|
||||
const isInvited = tenant.status === TenantStatus.Invited;
|
||||
const isExisting = tenant.status === TenantStatus.Existing;
|
||||
|
||||
const estimatedPrice = useMemo(() => {
|
||||
if (!currentPlan || !stats || !currency || !isActive) return 0;
|
||||
return calculateEstimatedPrice(currentPlan, currency, stats);
|
||||
}, [currentPlan, stats, currency, isActive, calculateEstimatedPrice]);
|
||||
|
||||
if (isLoading) return <Skeleton width={70} height={20} />;
|
||||
if (isInvited || isExisting) return <EmptyRow />;
|
||||
|
||||
@@ -37,7 +29,7 @@ export const TenantPlanCostCell = ({ tenant }: Props) => {
|
||||
}
|
||||
>
|
||||
{currency == Currency.USD && "$ "}
|
||||
{estimatedPrice.toFixed(2)}
|
||||
{(isActive ? estimatedPrice : 0).toFixed(2)}
|
||||
{currency == Currency.EUR && " €"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Loaded 20 of 60 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user