Compare commits

..
Author SHA1 Message Date
riccardom b786c3395a Drop the self-hosted caveat from the Debug Bundle Upload help text
The client no longer keeps the bundle on the peer when a self-hosted deployment
publishes no destination: netbirdio/netbird now falls back to the upload service
NetBird runs for every deployment, so the field's help text described behaviour
that is not there.
2026-09-11 12:44:57 +02:00
riccardom 3c009ddc0d Add the debug-bundle upload destination to the Clients settings
A debug bundle carries peer logs, routes, DNS and firewall state, and until now
every client path that uploads one without a human picking a destination sent it
to the service NetBird runs, with no way for a self-hosted operator to point it
elsewhere. netbirdio/netbird publishes the destination to peers from the account
settings; this exposes that setting.

Empty keeps the current behaviour for cloud accounts and, for a self-hosted
deployment, makes the peers keep the bundle locally instead. The URL is
validated as https-with-host on the client too, matching what the API accepts:
the peer fetches an upload URL from this endpoint and then PUTs the bundle to
whatever comes back, so a plaintext hop would expose both.
2026-09-09 09:38:55 +02:00
209 changed files with 3675 additions and 2324 deletions

No files matched your search

-29
View File
@@ -98,24 +98,6 @@ jobs:
type=ref,event=pr type=ref,event=pr
type=sha type=sha
- name: Docker meta (rootless)
id: meta_rootless
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKERHUB_IMAGE }}
${{ env.GHCR_IMAGE }}
flavor: |
latest=false
suffix=-rootless,onlatest=true
tags: |
type=schedule
type=ref,event=branch
type=ref,event=tag
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
type=ref,event=pr
type=sha
- name: Log in to Docker Hub - name: Log in to Docker Hub
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -141,18 +123,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
- name: Build and push rootless Docker image
uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile.rootless
push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
platforms: linux/amd64,linux/arm64,linux/arm
tags: ${{ steps.meta_rootless.outputs.tags }}
labels: ${{ steps.meta_rootless.outputs.labels }}
- if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
run: | run: |
echo '### Pushed tags' >> $GITHUB_STEP_SUMMARY echo '### Pushed tags' >> $GITHUB_STEP_SUMMARY
echo '${{ steps.meta.outputs.tags }}' >> $GITHUB_STEP_SUMMARY echo '${{ steps.meta.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
echo '${{ steps.meta_rootless.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
+1 -6
View File
@@ -35,9 +35,6 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# local UI sandbox (throwaway preview harness — never commit)
/src/app/sandbox/
# config # config
.local*config*.json .local*config*.json
.test-config.json .test-config.json
@@ -56,6 +53,4 @@ playwright-report*.zip
/public/ironrdp-pkg/ /public/ironrdp-pkg/
/public/netbird.wasm /public/netbird.wasm
.idea .idea
src/.local-config* src/.local-config*
# local mock data (throwaway preview — never commit)
/src/utils/mockData.ts
+12 -24
View File
@@ -11,7 +11,6 @@
# ./build.sh # build + export netbird-dashboard.tar.gz # ./build.sh # build + export netbird-dashboard.tar.gz
# IMAGE_TAG=v1 ./build.sh # override the image tag # IMAGE_TAG=v1 ./build.sh # override the image tag
# SKIP_BUILD=1 ./build.sh # reuse an existing out/ (skip npm build) # SKIP_BUILD=1 ./build.sh # reuse an existing out/ (skip npm build)
# ROOTLESS=1 ./build.sh # build the amd64-rootless image variant
# #
set -euo pipefail set -euo pipefail
@@ -20,26 +19,8 @@ cd "$(dirname "$0")"
IMAGE_NAME="${IMAGE_NAME:-netbird-dashboard}" IMAGE_NAME="${IMAGE_NAME:-netbird-dashboard}"
IMAGE_TAG="${IMAGE_TAG:-amd64}" IMAGE_TAG="${IMAGE_TAG:-amd64}"
DOCKERFILE="docker/Dockerfile"
CONTAINER_PORT=80
OUTPUT_SUFFIX=""
case "${ROOTLESS:-0}" in
0) ;;
1)
DOCKERFILE="docker/Dockerfile.rootless"
CONTAINER_PORT=8080
OUTPUT_SUFFIX="-rootless"
[[ "${IMAGE_TAG}" == *-rootless ]] || IMAGE_TAG="${IMAGE_TAG}-rootless"
;;
*)
echo "ERROR: ROOTLESS must be 0 or 1." >&2
exit 1
;;
esac
IMAGE="${IMAGE_NAME}:${IMAGE_TAG}" IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
OUTPUT="${OUTPUT:-${IMAGE_NAME}${OUTPUT_SUFFIX}.tar.gz}" OUTPUT="${OUTPUT:-${IMAGE_NAME}.tar.gz}"
# Build the static export (out/) unless told to reuse an existing one. Next 16 # Build the static export (out/) unless told to reuse an existing one. Next 16
# no longer runs ESLint during `next build`; types are checked by the build via # no longer runs ESLint during `next build`; types are checked by the build via
@@ -58,10 +39,17 @@ if [[ ! -d out ]]; then
exit 1 exit 1
fi fi
# The .dockerignore lives under docker/, but Docker reads it from the build
# context root. Stage it for the duration of the build so node_modules/.next
# don't get shipped into the build context.
cleanup() { rm -f .dockerignore; }
trap cleanup EXIT
cp docker/.dockerignore .dockerignore
echo "==> Building ${IMAGE} for linux/amd64" echo "==> Building ${IMAGE} for linux/amd64"
docker build \ docker build \
--platform linux/amd64 \ --platform linux/amd64 \
-f "${DOCKERFILE}" \ -f docker/Dockerfile \
-t "${IMAGE}" \ -t "${IMAGE}" \
. .
@@ -72,13 +60,13 @@ echo "==> Done: ${OUTPUT} ($(du -h "${OUTPUT}" | cut -f1))"
echo echo
echo "Copy and run on the server:" echo "Copy and run on the server:"
echo " scp ${OUTPUT} user@your-server:/tmp/" echo " scp ${OUTPUT} user@your-server:/tmp/"
echo " docker load < /tmp/${OUTPUT##*/}" echo " docker load < /tmp/${OUTPUT}"
echo echo
echo "Run (nginx serves on container port ${CONTAINER_PORT}). Set the runtime env vars the" echo "Run (nginx serves on container port 80). Set the runtime env vars the"
echo "dashboard templates at startup — for the Agent Network onboarding test," echo "dashboard templates at startup — for the Agent Network onboarding test,"
echo "flip NETBIRD_AGENT_NETWORK_ONLY=true:" echo "flip NETBIRD_AGENT_NETWORK_ONLY=true:"
echo " docker run -d --name ${IMAGE_NAME} --restart unless-stopped \\" echo " docker run -d --name ${IMAGE_NAME} --restart unless-stopped \\"
echo " -p 8080:${CONTAINER_PORT} \\" echo " -p 8080:80 \\"
echo " -e NETBIRD_MGMT_API_ENDPOINT=https://your-mgmt:443 \\" echo " -e NETBIRD_MGMT_API_ENDPOINT=https://your-mgmt:443 \\"
echo " -e AUTH_AUTHORITY=https://your-idp/realms/netbird \\" echo " -e AUTH_AUTHORITY=https://your-idp/realms/netbird \\"
echo " -e AUTH_CLIENT_ID=netbird-dashboard \\" echo " -e AUTH_CLIENT_ID=netbird-dashboard \\"
File renamed without changes.
-29
View File
@@ -1,29 +0,0 @@
FROM alpine:3.24
RUN apk add --no-cache bash ca-certificates gettext nginx tzdata && \
rm -rf /var/cache/apk/* && \
mkdir -p /tmp/nginx
STOPSIGNAL SIGINT
EXPOSE 8080
WORKDIR /usr/share/nginx/html
COPY docker/default.conf /etc/nginx/http.d/default.conf
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/rootless.conf /etc/nginx/http.d/rootless.conf
COPY docker/init_react_envs.sh /usr/local/init_react_envs.sh
COPY out/ /usr/share/nginx/html/
RUN sed -i \
-e 's/listen 80 default_server;/listen 8080 default_server;/' \
-e 's/listen \[::\]:80 default_server;/listen [::]:8080 default_server;/' \
/etc/nginx/http.d/default.conf && \
sed -i '/^user nginx;$/d' /etc/nginx/nginx.conf && \
chmod +x /usr/local/init_react_envs.sh && \
chgrp -R 0 /etc/nginx/http.d /usr/share/nginx/html /tmp/nginx && \
chmod -R g=u /etc/nginx/http.d /usr/share/nginx/html /tmp/nginx
USER 10001:0
ENTRYPOINT ["/usr/local/init_react_envs.sh"]
CMD ["/usr/sbin/nginx", "-e", "/dev/stderr", "-g", "pid /tmp/nginx.pid;"]
+10 -46
View File
@@ -1,59 +1,23 @@
# NetBird Dashboard # NetBird Dashboard
NetBird Dashboard is NetBirds Management server UI. It allows users to signin, view setup keys and manage peers. This image is **not ready** for production use.
NetBird Dashboard is NetBird's management server UI.
## Tags ## Tags
```latest``` ```vX.X.X``` not available yet.
Each published image tag also has a rootless variant with the `-rootless` ```main``` built on every PR being merged to the repository
suffix. For example, `main` is also published as `main-rootless`.
## How to use this image ## How to use this image
HTTP run:
HTTP:
```shell ```shell
docker run -d --rm -p 80:80 netbirdio/dashboard:main docker run -d --rm -p 80:80 wiretrustee/dashboard:main
``` ```
Using SSL certificate from Let's Encrypt®:
Using an SSL certificate from Let's Encrypt:
```shell ```shell
docker run -d --rm -p 80:80 -p 443:443 \ docker run -d --rm -p 80:80 -p 443:443 \
-e LETSENCRYPT_DOMAIN=app.mydomain.com \ -e LETSENCRYPT_DOMAIN=app.mydomain.com \
-e LETSENCRYPT_EMAIL=hello@mydomain.com \ -e LETSENCRYPT_EMAIL=hello@mydomain.com \
netbirdio/dashboard:main netbirdio/dashboard:main
``` ```
> For SSL generation, you need to run this image in a server with proper public IP and a domain name pointing to it.
For certificate generation, the server needs a public IP and a domain name
pointing to it.
### Rootless and OpenShift
The rootless image listens on port `8080`, runs as UID `10001` by default, and
supports OpenShift's arbitrary UID when the container retains GID `0`:
```shell
docker run -d --rm \
--user 12345:0 \
-p 8080:8080 \
-e AUTH_AUTHORITY=https://idp.example.com \
-e AUTH_CLIENT_ID=netbird-dashboard \
-e AUTH_AUDIENCE=netbird-dashboard \
-e AUTH_SUPPORTED_SCOPES='openid profile email' \
-e USE_AUTH0=false \
-e NETBIRD_MGMT_API_ENDPOINT=https://api.example.com \
netbirdio/dashboard:main-rootless
```
The rootless image does not include Certbot or expose HTTPS. Terminate TLS at
the OpenShift Route, Ingress, or another reverse proxy.
## Environment variables ## Environment variables
* ```NGINX_SSL_PORT``` Changes the port that Nginx listens to. Defaults to ```443```
- `NGINX_SSL_PORT` changes the port that Nginx listens to. Defaults to `443`. * ```LETSENCRYPT_DOMAIN``` Enables Certbot`s client execution for the specified domain. Defaults to ```none```
- `LETSENCRYPT_DOMAIN` enables Certbot for the specified domain. Defaults to * ```LETSENCRYPT_EMAIL``` Email used in Certbot`s client execution to register the certificate request. Defaults to ```example@local```
`none`.
- `LETSENCRYPT_EMAIL` is used to register the certificate request. Defaults to
`example@local`.
These certificate variables apply only to the standard image.
-6
View File
@@ -173,12 +173,6 @@ for f in $(grep -R -l AUTH_SUPPORTED_SCOPES /usr/share/nginx/html); do
rm "$f".copy rm "$f".copy
done done
# When a command is provided, initialization happens before the server starts.
# The rootless image uses this path so nginx never needs to be reloaded.
if (( $# > 0 )); then
exec "$@"
fi
# Reload nginx so the patched CSP header takes effect. # Reload nginx so the patched CSP header takes effect.
# supervisord starts nginx (priority 100) before this script (priority 201) and # supervisord starts nginx (priority 100) before this script (priority 201) and
# never reloads it, so without this nginx keeps serving the static default.conf # never reloads it, so without this nginx keeps serving the static default.conf
-7
View File
@@ -1,7 +0,0 @@
# nginx includes this file inside the http context.
# All runtime state stays in a directory writable by the arbitrary UID used by OpenShift.
client_body_temp_path /tmp/nginx/client_temp;
proxy_temp_path /tmp/nginx/proxy_temp;
fastcgi_temp_path /tmp/nginx/fastcgi_temp;
uwsgi_temp_path /tmp/nginx/uwsgi_temp;
scgi_temp_path /tmp/nginx/scgi_temp;
-1
View File
@@ -1,7 +1,6 @@
# Ignore zitadel environment # Ignore zitadel environment
.env .env
Caddyfile Caddyfile
agentgateway-stub.Caddyfile
management.json management.json
turnserver.conf turnserver.conf
zitadel.env zitadel.env
+1 -1
View File
@@ -19,7 +19,7 @@ check_docker_compose() {
DOCKER_COMPOSE_COMMAND=$(check_docker_compose) DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
$DOCKER_COMPOSE_COMMAND down --volumes $DOCKER_COMPOSE_COMMAND down --volumes
rm -f docker-compose.yml Caddyfile agentgateway-stub.Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env rm -f docker-compose.yml Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env
rm -rf proxy-certs proxy-certs-no-ports rm -rf proxy-certs proxy-certs-no-ports
rm -f ../../.test-config.json ../playwright.env.json rm -f ../../.test-config.json ../playwright.env.json
rm -f ../fixtures/auth/owner.json ../fixtures/auth/user.json rm -f ../fixtures/auth/owner.json ../fixtures/auth/user.json
+1 -45
View File
@@ -532,7 +532,7 @@ initEnvironment() {
echo "Generated files already exist, if you want to reinitialize the environment, please remove them first." echo "Generated files already exist, if you want to reinitialize the environment, please remove them first."
echo "You can use the following commands:" echo "You can use the following commands:"
echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes" echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes"
echo " rm -f docker-compose.yml Caddyfile agentgateway-stub.Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports" echo " rm -f docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports"
echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard." echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard."
exit 1 exit 1
fi fi
@@ -540,7 +540,6 @@ initEnvironment() {
echo Rendering initial files... echo Rendering initial files...
renderDockerCompose > docker-compose.yml renderDockerCompose > docker-compose.yml
renderCaddyfile > Caddyfile renderCaddyfile > Caddyfile
renderAgentGatewayStub > agentgateway-stub.Caddyfile
renderZitadelEnv > zitadel.env renderZitadelEnv > zitadel.env
echo "" > turnserver.conf echo "" > turnserver.conf
echo "" > management.json echo "" > management.json
@@ -613,30 +612,6 @@ initEnvironment() {
echo "Password: $ZITADEL_ADMIN_PASSWORD" | tee -a .env echo "Password: $ZITADEL_ADMIN_PASSWORD" | tee -a .env
} }
# Saving an Agent Network provider makes management call the upstream and
# refuses the save unless it answers a model listing, so a provider spec needs
# a reachable OpenAI-compatible endpoint. agentgateway is self-hosted and has
# no public one; this stub stands in for it, inside the compose network where
# management can reach it and without depending on a vendor being up.
renderAgentGatewayStub() {
cat <<'EOF'
{
admin off
auto_https off
}
:8088 {
handle /v1/models {
header Content-Type application/json
respond `{"object":"list","data":[{"id":"agentgateway-e2e-model","object":"model","owned_by":"netbird-e2e"}]}` 200
}
handle {
respond 404
}
}
EOF
}
renderCaddyfile() { renderCaddyfile() {
cat <<EOF cat <<EOF
{ {
@@ -843,15 +818,6 @@ services:
volumes: volumes:
- netbird_caddy_data:/data - netbird_caddy_data:/data
- ./Caddyfile:/etc/caddy/Caddyfile - ./Caddyfile:/etc/caddy/Caddyfile
# Stand-in upstream for the Agent Network provider specs: answers the model
# listing management probes before it accepts a provider save. Reachable
# only from inside the compose network, at http://agentgateway-stub:8088.
agentgateway-stub:
image: caddy
restart: unless-stopped
networks: [ netbird ]
volumes:
- ./agentgateway-stub.Caddyfile:/etc/caddy/Caddyfile
# Management # Management
management: management:
image: ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG} image: ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG}
@@ -926,16 +892,6 @@ services:
# GeoLite2 download removes a startup stall of up to 2 minutes # GeoLite2 download removes a startup stall of up to 2 minutes
# when pkgs.netbird.io is slow. # when pkgs.netbird.io is slow.
- NB_PROXY_DISABLE_GEOLOCATION=true - NB_PROXY_DISABLE_GEOLOCATION=true
# Declare the private capability so this cluster can host the agent
# network endpoint. That endpoint is a private service — reachable only
# from connected peers — and management refuses to bootstrap it onto a
# cluster without a private-capable proxy; the connect-provider wizard
# reads the same flag (supports_private) to pick its cluster. Without
# it no cluster qualifies and the wizard stops at the provider tab,
# which strands every spec that walks through it. The no-ports proxy
# below stays centralised, so the pick has to skip past a cluster that
# does not qualify.
- NB_PROXY_PRIVATE=true
volumes: volumes:
- ./proxy-certs:/certs:ro - ./proxy-certs:/certs:ro
command: [ command: [
-25
View File
@@ -24,31 +24,6 @@ export async function openControlCenter(page: Page, tab?: FlowView) {
await dismissBlockingOverlays(page); await dismissBlockingOverlays(page);
} }
/**
* Opens the groups view already showing `groupName`.
*
* The view picks its own group on first render (getFirstGroup: the highest
* peers_count that is a policy source), and that ranking runs over every group
* in the account — including ones a spec on the other worker seeded moments
* ago. Deleting the spec's own fixtures first is not enough to pin it, so
* select the group by name and let the view rebuild around it.
*/
export async function openGroupView(page: Page, groupName: string) {
await openControlCenter(page, "groups");
const selector = canvasNode(page, "select-group-node");
await expect(selector).toBeVisible();
// Unforced on purpose: the opening fitView animates the canvas, and the
// actionability check is what waits for the node to stop moving.
await selector.click();
await page.getByTestId("select-dropdown-search").fill(groupName);
await page
.locator('[role="option"]')
.filter({ hasText: groupName })
.first()
.click({ force: true });
await expect(selector).toContainText(groupName);
}
export async function switchFlowView(page: Page, view: FlowView) { export async function switchFlowView(page: Page, view: FlowView) {
await dismissBlockingOverlays(page); await dismissBlockingOverlays(page);
await page.getByTestId(`cc-flow-${view}`).click({ force: true }); await page.getByTestId(`cc-flow-${view}`).click({ force: true });
@@ -59,12 +59,7 @@ test.describe
.getByTestId("agent-network-provider-option-agentgateway") .getByTestId("agent-network-provider-option-agentgateway")
.click({ force: true }); .click({ force: true });
// Saving a provider makes management call the upstream, and it refuses const upstreamURL = "https://agentgateway.e2e.example";
// the save with a 422 unless the upstream answers a model listing — so a
// made-up host cannot be used here. agentgateway is self-hosted and has
// no public endpoint to point at, so create-test-env.sh runs a stub that
// answers that probe inside the compose network.
const upstreamURL = "http://agentgateway-stub:8088";
await page await page
.getByTestId("agent-network-provider-upstream-url") .getByTestId("agent-network-provider-upstream-url")
.fill(upstreamURL); .fill(upstreamURL);
@@ -1,206 +0,0 @@
/**
* Agent Network bootstrap-cluster spec.
*
* The account's agent network endpoint is assigned once, from the proxy
* cluster the connect-provider wizard pins it to, and it is immutable after
* that. Not every live cluster can host it: the endpoint is a private service,
* reachable only from connected peers and authorised by their tunnel identity,
* which only a cluster with private capabilities can serve. Management
* reports that per cluster as supports_private and refuses a
* bootstrap onto a cluster without it.
*
* This spec pins the wizard's half of that contract: it picks a
* private-capable cluster even when a centralised one is listed first, and it
* blocks setup — with a message naming what is missing — when no listed
* cluster qualifies. Both the settings and domains routes are mocked, so the
* assertions do not depend on which clusters the test backend happens to have
* connected, and no provider is created against it.
*/
import { type Browser, expect, type Page, test } from "@playwright/test";
import { loginToApp } from "../helpers/auth";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
// Listed first, and unusable: a centralised proxy cluster. Picking it is the
// bug this spec guards against.
const CENTRAL_CLUSTER = "central.proxy.netbird.io";
// Listed second, and the only valid pick.
const PRIVATE_CLUSTER = "private.proxy.netbird.io";
const UNBOOTSTRAPPED_DEFAULTS = {
endpoint: "",
proxy_address: "",
dedicated: false,
enable_log_collection: true,
enable_prompt_collection: false,
redact_pii: false,
access_log_retention_days: 30,
};
const BOOTSTRAPPED_SETTINGS = {
...UNBOOTSTRAPPED_DEFAULTS,
endpoint: `violet.${PRIVATE_CLUSTER}`,
proxy_address: PRIVATE_CLUSTER,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
/** A free (cluster-backed) domain as /reverse-proxies/domains reports it. */
function freeDomain(name: string, supportsPrivate: boolean) {
return {
id: name,
domain: name,
validated: true,
type: "free",
supports_private: supportsPrivate,
supports_custom_ports: true,
require_subdomain: true,
};
}
async function json(route: import("@playwright/test").Route, body: unknown) {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
}
/**
* Opens the providers page for an account that has not been bootstrapped,
* with the given clusters on the domains endpoint. Returns the page plus a
* promise-friendly getter for the bootstrap POST body, which is captured and
* answered locally so the real backend is never mutated.
*/
async function openWizard(
browser: Browser,
domains: ReturnType<typeof freeDomain>[],
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
try {
await context.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch (e) {}
},
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
);
const page = await context.newPage();
await page.route("**/api/agent-network/settings", (route) => {
const method = route.request().method();
if (method === "GET") return json(route, UNBOOTSTRAPPED_DEFAULTS);
if (method === "POST") return json(route, BOOTSTRAPPED_SETTINGS);
return route.continue();
});
await page.route("**/api/reverse-proxies/domains", (route) =>
route.request().method() === "GET"
? json(route, domains)
: route.continue(),
);
// Keep the create hermetic: the wizard only reaches this once the
// bootstrap it does first has succeeded.
await page.route("**/api/agent-network/providers", (route) =>
route.request().method() === "POST"
? json(route, { id: "prov-e2e", name: "e2e-provider" })
: route.continue(),
);
await loginToApp(page, "owner");
await page.goto("/agent-network/providers");
await page.keyboard.press("Escape");
return { page, close: () => context.close() };
} catch (e) {
await context.close();
throw e;
}
}
/** Fills the provider tab with the minimum a create needs. */
async function fillProviderTab(page: Page) {
await page
.getByPlaceholder("https://api.openai.com")
.fill("https://api.openai.com");
await page.getByPlaceholder("sk-...").first().fill("sk-e2e-bootstrap-key");
}
test.describe("Agent Network bootstrap cluster @agent-network", () => {
test("bootstraps onto a private-capable cluster, skipping a centralised one listed first", async ({
browser,
}) => {
const { page, close } = await openWizard(browser, [
freeDomain(CENTRAL_CLUSTER, false),
freeDomain(PRIVATE_CLUSTER, true),
]);
try {
await page
.getByRole("button", { name: "Connect Provider" })
.first()
.click({ force: true });
// The unusable cluster is listed, but it is not a blocker: a usable one
// exists, so the wizard proceeds without the warning.
await expect(
page.getByTestId("agent-network-no-cluster-callout"),
).toHaveCount(0);
await fillProviderTab(page);
const bootstrap = page.waitForResponse(
(resp) =>
resp.url().includes("/agent-network/settings") &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await page
.getByRole("button", { name: "Continue" })
.click({ force: true });
await page
.getByRole("button", { name: "Connect Provider" })
.last()
.click({ force: true });
const payload = (await bootstrap).request().postDataJSON();
expect(payload.proxy_address).toBe(PRIVATE_CLUSTER);
} finally {
await close();
}
});
test("blocks setup when no listed cluster has private capabilities", async ({
browser,
}) => {
const { page, close } = await openWizard(browser, [
freeDomain(CENTRAL_CLUSTER, false),
]);
try {
await page
.getByRole("button", { name: "Connect Provider" })
.first()
.click({ force: true });
const callout = page.getByTestId("agent-network-no-cluster-callout");
await expect(callout).toBeVisible({ timeout: 10_000 });
await expect(callout).toContainText("private capabilities");
// The fix lives in the agent network's own Clusters section, not the
// generic Reverse Proxy page, so that is where the callout sends users.
await expect(callout.getByRole("link")).toHaveAttribute(
"href",
"/agent-network/configuration?tab=clusters",
);
// With nothing to pin to there is no bootstrap to make, so the wizard
// cannot advance — rather than sending a request management refuses.
await fillProviderTab(page);
await expect(
page.getByRole("button", { name: "Continue" }),
).toBeDisabled();
} finally {
await close();
}
});
});
@@ -364,17 +364,13 @@ test.describe.serial("Control Center Draft Matrix @control-center", () => {
}) => { }) => {
const group = await place(page, "group", 0.6, 0.5); const group = await place(page, "group", 0.6, 0.5);
await expectChangeCount(page, 1); await expectChangeCount(page, 1);
// React Flow ignores delete keys while an input is focused, and the panel's
// search takes focus on click, so blur it before pressing Backspace.
await group.click(); await group.click();
await expect(group).toHaveClass(/selected/); await expect(group).toHaveClass(/selected/);
// React Flow ignores delete keys while an input is focused, and the panel await page.evaluate(
// the click opens takes focus into its search — from an effect, about () => (document.activeElement as HTMLElement | null)?.blur(),
// 100ms later. Blurring before that lands leaves the input to claim focus );
// afterwards and swallow the Backspace, so wait for the focus, then drop
// it.
const panelSearch = page.locator("#cc-group-panel input").first();
await expect(panelSearch).toBeFocused();
await panelSearch.blur();
await expect(panelSearch).not.toBeFocused();
await page.keyboard.press("Backspace"); await page.keyboard.press("Backspace");
await expect(canvasNode(page, "group-new-")).toHaveCount(0); await expect(canvasNode(page, "group-new-")).toHaveCount(0);
await expectChangeCount(page, 0); await expectChangeCount(page, 0);
@@ -19,7 +19,6 @@ import {
dismissBlockingOverlays, dismissBlockingOverlays,
enterDraft, enterDraft,
openControlCenter, openControlCenter,
openGroupView,
resetDraftState, resetDraftState,
reviewButton, reviewButton,
} from "../helpers/control-center"; } from "../helpers/control-center";
@@ -38,6 +37,7 @@ test.describe
await deleteGroupsByPrefix(page, PREFIX); await deleteGroupsByPrefix(page, PREFIX);
}); });
// Clean-slated so the draft group view's auto-select is deterministic.
async function seedPolicyGroupView(page: Page) { async function seedPolicyGroupView(page: Page) {
await deletePoliciesBySubstring(page, PREFIX); await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX); await deleteGroupsByPrefix(page, PREFIX);
@@ -49,9 +49,7 @@ test.describe
src.id, src.id,
dst.id, dst.id,
); );
// Selected by name: the view's own pick ranks every group in the account, await openControlCenter(page, "groups");
// so a peer-bearing group from another spec on the other worker wins it.
await openGroupView(page, src.name);
await enterDraft(page); await enterDraft(page);
const policyNode = canvasNode(page, `policy-${policy.id}`); const policyNode = canvasNode(page, `policy-${policy.id}`);
await expect(policyNode).toBeVisible({ timeout: 15_000 }); await expect(policyNode).toBeVisible({ timeout: 15_000 });
+14 -7
View File
@@ -18,7 +18,6 @@ import {
clickContextMenuItem, clickContextMenuItem,
dismissBlockingOverlays, dismissBlockingOverlays,
openControlCenter, openControlCenter,
openGroupView,
resetDraftState, resetDraftState,
switchFlowView, switchFlowView,
} from "../helpers/control-center"; } from "../helpers/control-center";
@@ -48,6 +47,8 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
}); });
async function seedPolicyAndOpenGroupView(page: Page, enabled = true) { async function seedPolicyAndOpenGroupView(page: Page, enabled = true) {
// The group view auto-selects a group that HAS a policy, so stale policies
// would make the rendered group non-deterministic.
await deletePoliciesBySubstring(page, PREFIX); await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX); await deleteGroupsByPrefix(page, PREFIX);
@@ -63,10 +64,15 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
); );
const policyNode = canvasNode(page, `policy-${policy.id}`); const policyNode = canvasNode(page, `policy-${policy.id}`);
// Selecting src by name rather than trusting the view's own pick: the // A stale SWR read or a build/fit race can fail to place the node.
// auto-select ranks every group in the account, so a peer-bearing group for (let attempt = 0; attempt < 3; attempt++) {
// another spec seeded on the other worker outranks this one. await openControlCenter(page, "groups");
await openGroupView(page, src.name); const shown = await policyNode
.waitFor({ state: "visible", timeout: 10_000 })
.then(() => true)
.catch(() => false);
if (shown) break;
}
await expect(policyNode).toBeVisible({ timeout: 10_000 }); await expect(policyNode).toBeVisible({ timeout: 10_000 });
return { src, dst, policy, policyNode }; return { src, dst, policy, policyNode };
} }
@@ -506,6 +512,7 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
test("Should save group resource membership in live mode (correct payload)", async ({ test("Should save group resource membership in live mode (correct payload)", async ({
dashboardAsOwner: page, dashboardAsOwner: page,
}) => { }) => {
// Clean slate so the group view deterministically lands on our group.
await deletePoliciesBySubstring(page, PREFIX); await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX); await deleteGroupsByPrefix(page, PREFIX);
@@ -522,7 +529,7 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
[], [],
); );
await openGroupView(page, src.name); await openControlCenter(page, "groups");
const groupNode = canvasNode(page, `group-${dst.id}`); const groupNode = canvasNode(page, `group-${dst.id}`);
await expect(groupNode).toBeVisible({ timeout: 15_000 }); await expect(groupNode).toBeVisible({ timeout: 15_000 });
@@ -603,7 +610,7 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
); );
await createPolicy(page, generateRandomName(PREFIX), src.id, d2.id); await createPolicy(page, generateRandomName(PREFIX), src.id, d2.id);
await openGroupView(page, src.name); await openControlCenter(page, "groups");
const policyNode = canvasNode(page, `policy-${p1.id}`); const policyNode = canvasNode(page, `policy-${p1.id}`);
await expect(policyNode).toBeVisible({ timeout: 15_000 }); await expect(policyNode).toBeVisible({ timeout: 15_000 });
await expect( await expect(
+463 -570
View File
File diff suppressed because it is too large. Load diff
+4 -8
View File
@@ -48,7 +48,7 @@
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/lodash": "4.17.24", "@types/lodash": "4.17.24",
"@types/node": "20.19.43", "@types/node": "20.10.6",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/react-window": "^1.8.8", "@types/react-window": "^1.8.8",
@@ -75,10 +75,11 @@
"js-cookie": "^3.0.7", "js-cookie": "^3.0.7",
"lodash": "4.18.1", "lodash": "4.18.1",
"lucide-react": "^0.566.0", "lucide-react": "^0.566.0",
"next": "16.3.4", "next": "16.3.0",
"punycode": "^2.3.1", "punycode": "^2.3.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-chartjs-2": "^5.3.0", "react-chartjs-2": "^5.3.0",
"react-confetti-explosion": "^3.0.3",
"react-day-picker": "^9.13.0", "react-day-picker": "^9.13.0",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-ga4": "^2.1.0", "react-ga4": "^2.1.0",
@@ -100,6 +101,7 @@
"minimatch": ">=10.2.1" "minimatch": ">=10.2.1"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^9.5.1",
"@playwright/test": "^1.52.0", "@playwright/test": "^1.52.0",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
@@ -112,11 +114,5 @@
"prettier": "3.0.3", "prettier": "3.0.3",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"vitest": "^4.1.10" "vitest": "^4.1.10"
},
"allowScripts": {
"@axa-fr/oidc-client": false,
"@axa-fr/react-oidc": false,
"fsevents": false,
"unrs-resolver": false
} }
} }
@@ -60,7 +60,7 @@ export default function Integrations() {
(!isNetBirdCloud() && (permission?.settings?.read ?? false)) (!isNetBirdCloud() && (permission?.settings?.read ?? false))
} }
> >
<div className={"w-full"}> <div className={"border-l border-nb-gray-930 w-full"}>
<IdentityProviderTab /> <IdentityProviderTab />
<SSOTab /> <SSOTab />
<EventStreamingTab /> <EventStreamingTab />
Loaded 20 of 209 files, more files were not shown because too many files have changed in this diff. Show more