mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0cca1aa31 | ||
|
|
6a26f546c5 | ||
|
|
5776a7dd5c | ||
|
|
208176ba9d | ||
|
|
8a1ba01ef2 | ||
|
|
da81cb7f50 | ||
|
|
f4e80d37c4 | ||
|
|
de045582e5 | ||
|
|
6a85748b91 | ||
|
|
a9a91905fa | ||
|
|
3459a4db28 | ||
|
|
e8df6c153c | ||
|
|
8023ff5047 | ||
|
|
32ecc374bb | ||
|
|
f3a4f22e1e | ||
|
|
968ca333c1 | ||
|
|
1e77129bdf | ||
|
|
0dfdaf62c6 | ||
|
|
2f74430616 | ||
|
|
b3cf73950e | ||
|
|
addea360a1 | ||
|
|
4a597a7fdd | ||
|
|
ba88b838f9 | ||
|
|
a8275e54d5 | ||
|
|
f77d0dc48e | ||
|
|
67cd3ddf08 | ||
|
|
d90fe688f5 | ||
|
|
6dd3dfa153 | ||
|
|
68128dffb7 | ||
|
|
daff0659d4 | ||
|
|
019320a481 | ||
|
|
d1a2d407b3 | ||
|
|
41043bb10c | ||
|
|
d4f6adb807 | ||
|
|
ccf417e103 | ||
|
|
6a6a3b86c4 | ||
|
|
9849e0a70c | ||
|
|
9249464d5c | ||
|
|
06daecf89a | ||
|
|
d58d50c4ec | ||
|
|
97007db62e |
No files matched your search
File renamed without changes.
@@ -98,6 +98,24 @@ 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
|
||||||
@@ -123,7 +141,18 @@ 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
|
||||||
+6
-1
@@ -35,6 +35,9 @@ 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
|
||||||
@@ -53,4 +56,6 @@ 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
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
# ./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
|
||||||
|
|
||||||
@@ -19,8 +20,26 @@ 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}.tar.gz}"
|
OUTPUT="${OUTPUT:-${IMAGE_NAME}${OUTPUT_SUFFIX}.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
|
||||||
@@ -39,17 +58,10 @@ 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 docker/Dockerfile \
|
-f "${DOCKERFILE}" \
|
||||||
-t "${IMAGE}" \
|
-t "${IMAGE}" \
|
||||||
.
|
.
|
||||||
|
|
||||||
@@ -60,13 +72,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 80). Set the runtime env vars the"
|
echo "Run (nginx serves on container port ${CONTAINER_PORT}). 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:80 \\"
|
echo " -p 8080:${CONTAINER_PORT} \\"
|
||||||
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 \\"
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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;"]
|
||||||
+47
-11
@@ -1,23 +1,59 @@
|
|||||||
# 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.
|
|
||||||
## Tags
|
|
||||||
```latest``` ```vX.X.X``` not available yet.
|
|
||||||
|
|
||||||
```main``` built on every PR being merged to the repository
|
NetBird Dashboard is NetBird's management server UI.
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
|
||||||
|
Each published image tag also has a rootless variant with the `-rootless`
|
||||||
|
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 wiretrustee/dashboard:main
|
docker run -d --rm -p 80:80 netbirdio/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```
|
|
||||||
* ```LETSENCRYPT_DOMAIN``` Enables Certbot`s client execution for the specified domain. Defaults to ```none```
|
- `NGINX_SSL_PORT` changes the port that Nginx listens to. Defaults to `443`.
|
||||||
* ```LETSENCRYPT_EMAIL``` Email used in Certbot`s client execution to register the certificate request. Defaults to ```example@local```
|
- `LETSENCRYPT_DOMAIN` enables Certbot for the specified domain. Defaults to
|
||||||
|
`none`.
|
||||||
|
- `LETSENCRYPT_EMAIL` is used to register the certificate request. Defaults to
|
||||||
|
`example@local`.
|
||||||
|
|
||||||
|
These certificate variables apply only to the standard image.
|
||||||
@@ -173,6 +173,12 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 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;
|
||||||
@@ -926,6 +926,16 @@ 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: [
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+570
-463
File diff suppressed because it is too large.
Load diff
+8
-4
@@ -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.10.6",
|
"@types/node": "20.19.43",
|
||||||
"@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,11 +75,10 @@
|
|||||||
"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.0",
|
"next": "16.3.4",
|
||||||
"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",
|
||||||
@@ -101,7 +100,6 @@
|
|||||||
"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",
|
||||||
@@ -114,5 +112,11 @@
|
|||||||
"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={"border-l border-nb-gray-930 w-full"}>
|
<div className={"w-full"}>
|
||||||
<IdentityProviderTab />
|
<IdentityProviderTab />
|
||||||
<SSOTab />
|
<SSOTab />
|
||||||
<EventStreamingTab />
|
<EventStreamingTab />
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export default function AgentNetworkConfigurationPage() {
|
|||||||
<GroupsProvider>
|
<GroupsProvider>
|
||||||
<PeersProvider>
|
<PeersProvider>
|
||||||
<AIProvidersProvider>
|
<AIProvidersProvider>
|
||||||
<div className={"border-l border-nb-gray-930 w-full"}>
|
<div className={"w-full"}>
|
||||||
<Tabs.Content
|
<Tabs.Content
|
||||||
value={TAB_BUDGET_SETTINGS}
|
value={TAB_BUDGET_SETTINGS}
|
||||||
className={"w-full"}
|
className={"w-full"}
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import React, { useState } from "react";
|
|||||||
import { useSWRConfig } from "swr";
|
import { useSWRConfig } from "swr";
|
||||||
import GroupsProvider from "@/contexts/GroupsProvider";
|
import GroupsProvider from "@/contexts/GroupsProvider";
|
||||||
import PeersProvider from "@/contexts/PeersProvider";
|
import PeersProvider from "@/contexts/PeersProvider";
|
||||||
|
import { useTheme } from "@/contexts/ThemeProvider";
|
||||||
|
import {
|
||||||
|
canvasBackground,
|
||||||
|
canvasDotColor,
|
||||||
|
} from "@/modules/control-center/utils/canvas-theme";
|
||||||
import PoliciesProvider from "@/contexts/PoliciesProvider";
|
import PoliciesProvider from "@/contexts/PoliciesProvider";
|
||||||
import { Network } from "@/interfaces/Network";
|
import { Network } from "@/interfaces/Network";
|
||||||
import PageContainer from "@/layouts/PageContainer";
|
import PageContainer from "@/layouts/PageContainer";
|
||||||
@@ -105,6 +110,8 @@ const PRO_OPTIONS = { hideAttribution: true };
|
|||||||
const DEFAULT_VIEWPORT = { x: 0, y: 0, zoom: EMPTY_STATE_ZOOM };
|
const DEFAULT_VIEWPORT = { x: 0, y: 0, zoom: EMPTY_STATE_ZOOM };
|
||||||
|
|
||||||
function ControlCenterCanvas() {
|
function ControlCenterCanvas() {
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const isLight = resolvedTheme === "light";
|
||||||
const canvas = useCanvasState();
|
const canvas = useCanvasState();
|
||||||
const ui = useControlCenterUI();
|
const ui = useControlCenterUI();
|
||||||
const draft = useDraft();
|
const draft = useDraft();
|
||||||
@@ -283,7 +290,7 @@ function ControlCenterCanvas() {
|
|||||||
onInit={stableOnInit}
|
onInit={stableOnInit}
|
||||||
maxZoom={DEFAULT_MAX_ZOOM}
|
maxZoom={DEFAULT_MAX_ZOOM}
|
||||||
minZoom={DEFAULT_MIN_ZOOM}
|
minZoom={DEFAULT_MIN_ZOOM}
|
||||||
colorMode={"dark"}
|
colorMode={isLight ? "light" : "dark"}
|
||||||
panOnDrag={canInteract}
|
panOnDrag={canInteract}
|
||||||
panOnScroll={draft.isSelectMode && !emptyState}
|
panOnScroll={draft.isSelectMode && !emptyState}
|
||||||
zoomOnScroll={canInteract}
|
zoomOnScroll={canInteract}
|
||||||
@@ -297,7 +304,11 @@ function ControlCenterCanvas() {
|
|||||||
selectionOnDrag={draft.isSelectMode && !emptyState}
|
selectionOnDrag={draft.isSelectMode && !emptyState}
|
||||||
selectionMode={SelectionMode.Partial}
|
selectionMode={SelectionMode.Partial}
|
||||||
>
|
>
|
||||||
<Background bgColor={"#181a1d"} gap={20} color={"#717171"} />
|
<Background
|
||||||
|
bgColor={canvasBackground(resolvedTheme)}
|
||||||
|
gap={20}
|
||||||
|
color={canvasDotColor(resolvedTheme)}
|
||||||
|
/>
|
||||||
<CanvasContextMenu onOpenChange={setContextMenuOpen} />
|
<CanvasContextMenu onOpenChange={setContextMenuOpen} />
|
||||||
<NodeContextMenu
|
<NodeContextMenu
|
||||||
position={nodeContextMenuPos}
|
position={nodeContextMenuPos}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ const GroupDetailsName = () => {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-8 w-8 items-center justify-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer",
|
"flex h-8 w-8 items-center justify-center gap-2 text-nb-gray-300 hover:text-nb-gray-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer",
|
||||||
!isAllowedToRename &&
|
!isAllowedToRename &&
|
||||||
"opacity-40 cursor-not-allowed pointer-events-none",
|
"opacity-40 cursor-not-allowed pointer-events-none",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ const PeerHeader = () => {
|
|||||||
<ModalTrigger>
|
<ModalTrigger>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
"flex h-8 w-8 items-center justify-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer"
|
"flex h-8 w-8 items-center justify-center gap-2 text-nb-gray-400 hover:text-nb-gray-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<PencilIcon size={16} />
|
<PencilIcon size={16} />
|
||||||
@@ -741,7 +741,7 @@ function EditableValue({
|
|||||||
<span>{value}</span>
|
<span>{value}</span>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
className="flex w-7 h-7 items-center justify-center gap-2 text-nb-gray-400 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer"
|
className="flex w-7 h-7 items-center justify-center gap-2 text-nb-gray-400 hover:text-nb-gray-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onEdit();
|
onEdit();
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default function NetBirdSettings() {
|
|||||||
page={"Settings"}
|
page={"Settings"}
|
||||||
hasAccess={permission?.billing?.read || permission?.settings?.read}
|
hasAccess={permission?.billing?.read || permission?.settings?.read}
|
||||||
>
|
>
|
||||||
<div className={"border-l border-nb-gray-930 w-full"}>
|
<div className={"w-full"}>
|
||||||
{account && <AuthenticationTab account={account} />}
|
{account && <AuthenticationTab account={account} />}
|
||||||
{permission.setup_keys.read && <SetupKeysTab />}
|
{permission.setup_keys.read && <SetupKeysTab />}
|
||||||
{account?.settings?.embedded_idp_enabled &&
|
{account?.settings?.embedded_idp_enabled &&
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
|||||||
<div className={"flex items-center gap-3"}>
|
<div className={"flex items-center gap-3"}>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
"w-10 h-10 rounded-full relative flex items-center justify-center text-white uppercase text-md font-medium bg-nb-gray-900"
|
"w-10 h-10 rounded-full relative flex items-center justify-center text-nb-gray-100 uppercase text-md font-medium bg-nb-gray-900"
|
||||||
}
|
}
|
||||||
style={
|
style={
|
||||||
isServiceUser
|
isServiceUser
|
||||||
|
|||||||
+67
-4
@@ -5,6 +5,69 @@
|
|||||||
:root {
|
:root {
|
||||||
--toasts-before: 0;
|
--toasts-before: 0;
|
||||||
--lift: 1;
|
--lift: 1;
|
||||||
|
|
||||||
|
/* nb-gray INVERTED ramp (light mode) — space-separated RGB channels.
|
||||||
|
High stops are surfaces (near-white), low stops are text (near-black).
|
||||||
|
DEFAULT (the app background) sits slightly below white so white
|
||||||
|
inputs, buttons and cards stand in front of it.
|
||||||
|
The ramp is pure neutral (R = G = B) and matches the desktop client's
|
||||||
|
light ramp (netbirdio/netbird#7344) so both UIs share one palette. */
|
||||||
|
--nb-gray-DEFAULT: 243 243 243;
|
||||||
|
--nb-gray-50: 26 26 26;
|
||||||
|
--nb-gray-100: 33 33 33;
|
||||||
|
--nb-gray-200: 50 50 50;
|
||||||
|
--nb-gray-250: 58 58 58;
|
||||||
|
--nb-gray-300: 77 77 77;
|
||||||
|
--nb-gray-350: 92 92 92;
|
||||||
|
--nb-gray-400: 108 108 108;
|
||||||
|
--nb-gray-500: 135 135 135;
|
||||||
|
--nb-gray-600: 154 154 154;
|
||||||
|
--nb-gray-700: 209 209 209;
|
||||||
|
--nb-gray-800: 226 226 226;
|
||||||
|
--nb-gray-850: 234 234 234;
|
||||||
|
--nb-gray-900: 238 238 238;
|
||||||
|
--nb-gray-910: 240 240 240;
|
||||||
|
--nb-gray-920: 243 243 243;
|
||||||
|
--nb-gray-925: 245 245 245;
|
||||||
|
--nb-gray-930: 246 246 246;
|
||||||
|
--nb-gray-935: 248 248 248;
|
||||||
|
--nb-gray-940: 250 250 250;
|
||||||
|
--nb-gray-950: 252 252 252;
|
||||||
|
--nb-gray-960: 255 255 255;
|
||||||
|
|
||||||
|
/* Loading-skeleton colours, consumed by ThemeProvider's SkeletonTheme. */
|
||||||
|
--skeleton-base: 238 238 238;
|
||||||
|
--skeleton-highlight: 247 247 247;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
/* nb-gray ORIGINAL ramp (dark mode) */
|
||||||
|
--nb-gray-DEFAULT: 24 26 29;
|
||||||
|
--nb-gray-50: 244 246 247;
|
||||||
|
--nb-gray-100: 228 231 233;
|
||||||
|
--nb-gray-200: 203 210 214;
|
||||||
|
--nb-gray-250: 183 192 198;
|
||||||
|
--nb-gray-300: 163 173 181;
|
||||||
|
--nb-gray-350: 143 156 168;
|
||||||
|
--nb-gray-400: 124 137 148;
|
||||||
|
--nb-gray-500: 97 110 121;
|
||||||
|
--nb-gray-600: 83 93 103;
|
||||||
|
--nb-gray-700: 71 78 87;
|
||||||
|
--nb-gray-800: 63 68 75;
|
||||||
|
--nb-gray-850: 54 59 64;
|
||||||
|
--nb-gray-900: 46 50 56;
|
||||||
|
--nb-gray-910: 43 47 51;
|
||||||
|
--nb-gray-920: 37 40 45;
|
||||||
|
--nb-gray-925: 30 33 35;
|
||||||
|
--nb-gray-930: 37 40 44;
|
||||||
|
--nb-gray-935: 31 33 36;
|
||||||
|
--nb-gray-940: 28 30 33;
|
||||||
|
--nb-gray-950: 24 26 29;
|
||||||
|
--nb-gray-960: 22 24 27;
|
||||||
|
|
||||||
|
/* Dark skeletons keep their existing nb-gray-920 / nb-gray-850 values. */
|
||||||
|
--skeleton-base: 37 40 45;
|
||||||
|
--skeleton-highlight: 54 59 64;
|
||||||
}
|
}
|
||||||
|
|
||||||
html{
|
html{
|
||||||
@@ -12,13 +75,13 @@ html{
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@apply text-2xl font-medium text-gray-700 dark:text-nb-gray-100 my-1;
|
@apply text-2xl font-medium text-nb-gray-100 my-1;
|
||||||
}
|
}
|
||||||
h2 {
|
h2 {
|
||||||
@apply text-xl font-medium text-gray-700 dark:text-nb-gray-100 my-1;
|
@apply text-xl font-medium text-nb-gray-100 my-1;
|
||||||
}
|
}
|
||||||
p {
|
p {
|
||||||
@apply font-light tracking-wide text-gray-700 dark:text-zinc-50 text-sm;
|
@apply font-light tracking-wide text-nb-gray-200 text-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.p-default {
|
.p-default {
|
||||||
@@ -95,7 +158,7 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.timescape input:focus {
|
.timescape input:focus {
|
||||||
@apply bg-nb-gray-700 text-white rounded py-1 px-0.5 border-0 outline-0;
|
@apply bg-nb-gray-700 text-nb-gray-50 rounded py-1 px-0.5 border-0 outline-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timescape .separator {
|
.timescape .separator {
|
||||||
|
|||||||
Loaded 20 of 198 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user