mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a75b0de3e |
No files matched your search
@@ -1,77 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build the netbird-dashboard Docker image for linux/amd64 and export it as a
|
||||
# portable tarball that can be loaded and run on a remote server.
|
||||
#
|
||||
# The dashboard image is nginx serving a Next.js *static export*: the Dockerfile
|
||||
# only COPYs the pre-built out/ directory, so we build out/ on the host first
|
||||
# (the static files are arch-independent) and then assemble the amd64 image.
|
||||
#
|
||||
# Usage:
|
||||
# ./build.sh # build + export netbird-dashboard.tar.gz
|
||||
# IMAGE_TAG=v1 ./build.sh # override the image tag
|
||||
# SKIP_BUILD=1 ./build.sh # reuse an existing out/ (skip npm build)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
# Always run relative to this script's directory (the project root).
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
IMAGE_NAME="${IMAGE_NAME:-netbird-dashboard}"
|
||||
IMAGE_TAG="${IMAGE_TAG:-amd64}"
|
||||
IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
OUTPUT="${OUTPUT:-${IMAGE_NAME}.tar.gz}"
|
||||
|
||||
# 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
|
||||
# tsc (verify separately with `npx tsc --noEmit`).
|
||||
if [[ "${SKIP_BUILD:-0}" != "1" ]]; then
|
||||
if [[ ! -d node_modules ]]; then
|
||||
echo "==> Installing dependencies (npm ci)"
|
||||
npm ci
|
||||
fi
|
||||
echo "==> Building static export (next build) into out/"
|
||||
npx next build
|
||||
fi
|
||||
|
||||
if [[ ! -d out ]]; then
|
||||
echo "ERROR: out/ not found — the static export did not build." >&2
|
||||
exit 1
|
||||
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"
|
||||
docker build \
|
||||
--platform linux/amd64 \
|
||||
-f docker/Dockerfile \
|
||||
-t "${IMAGE}" \
|
||||
.
|
||||
|
||||
echo "==> Exporting ${IMAGE} to ${OUTPUT}"
|
||||
docker save "${IMAGE}" | gzip > "${OUTPUT}"
|
||||
|
||||
echo "==> Done: ${OUTPUT} ($(du -h "${OUTPUT}" | cut -f1))"
|
||||
echo
|
||||
echo "Copy and run on the server:"
|
||||
echo " scp ${OUTPUT} user@your-server:/tmp/"
|
||||
echo " docker load < /tmp/${OUTPUT}"
|
||||
echo
|
||||
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 "flip NETBIRD_AGENT_NETWORK_ONLY=true:"
|
||||
echo " docker run -d --name ${IMAGE_NAME} --restart unless-stopped \\"
|
||||
echo " -p 8080:80 \\"
|
||||
echo " -e NETBIRD_MGMT_API_ENDPOINT=https://your-mgmt:443 \\"
|
||||
echo " -e AUTH_AUTHORITY=https://your-idp/realms/netbird \\"
|
||||
echo " -e AUTH_CLIENT_ID=netbird-dashboard \\"
|
||||
echo " -e AUTH_AUDIENCE=netbird-dashboard \\"
|
||||
echo " -e AUTH_SUPPORTED_SCOPES='openid profile email' \\"
|
||||
echo " -e USE_AUTH0=false \\"
|
||||
echo " -e NETBIRD_AGENT_NETWORK_ONLY=true \\"
|
||||
echo " ${IMAGE}"
|
||||
@@ -18,8 +18,6 @@
|
||||
"wasmPath": "$NETBIRD_WASM_PATH",
|
||||
"licensed": "$NETBIRD_LICENSED",
|
||||
"cloud": "$NETBIRD_CLOUD",
|
||||
"agentNetworkOnly": "$NETBIRD_AGENT_NETWORK_ONLY",
|
||||
"agentNetworkEnabled": "$NETBIRD_AGENT_NETWORK_ENABLED",
|
||||
"hubspotPortalId": "$NETBIRD_HUBSPOT_PORTAL_ID",
|
||||
"hubspotSignupFormId": "$NETBIRD_HUBSPOT_SIGNUP_FORM_ID",
|
||||
"hubspotOnboardingFormId": "$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID",
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# The image only needs the pre-built static export (out/) and the docker/
|
||||
# config files. Keep the build context small by dropping everything heavy and
|
||||
# irrelevant — without this, `docker build .` ships node_modules and .next.
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.github
|
||||
e2e
|
||||
src
|
||||
public
|
||||
*.tar.gz
|
||||
*.tar
|
||||
@@ -66,8 +66,6 @@ export NETBIRD_WASM_PATH=${NETBIRD_WASM_PATH}
|
||||
export NETBIRD_CSP=${NETBIRD_CSP}
|
||||
export NETBIRD_LICENSED=${NETBIRD_LICENSED:-false}
|
||||
export NETBIRD_CLOUD=${NETBIRD_CLOUD:-false}
|
||||
export NETBIRD_AGENT_NETWORK_ONLY=${NETBIRD_AGENT_NETWORK_ONLY:-false}
|
||||
export NETBIRD_AGENT_NETWORK_ENABLED=${NETBIRD_AGENT_NETWORK_ENABLED:-false}
|
||||
export NETBIRD_HUBSPOT_PORTAL_ID=${NETBIRD_HUBSPOT_PORTAL_ID}
|
||||
export NETBIRD_HUBSPOT_SIGNUP_FORM_ID=${NETBIRD_HUBSPOT_SIGNUP_FORM_ID}
|
||||
export NETBIRD_HUBSPOT_ONBOARDING_FORM_ID=${NETBIRD_HUBSPOT_ONBOARDING_FORM_ID}
|
||||
@@ -93,8 +91,6 @@ fi
|
||||
# Add AUTH_AUTHORITY to CSP
|
||||
if [[ -n "${AUTH_AUTHORITY}" ]]; then
|
||||
CSP_DOMAINS="$CSP_DOMAINS $AUTH_AUTHORITY"
|
||||
AUTH_AUTHORITY_ORIGIN=$(echo "$AUTH_AUTHORITY" | sed -E 's|^(https?://[^/]+).*|\1|')
|
||||
CSP_DOMAINS="$CSP_DOMAINS $AUTH_AUTHORITY_ORIGIN"
|
||||
fi
|
||||
|
||||
# Add AUTH_AUDIENCE to CSP
|
||||
@@ -141,18 +137,8 @@ CSP_CONNECT_SRC=$(echo $CSP_CONNECT_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' |
|
||||
CSP_FRAME_SRC=$(echo $CSP_FRAME_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
|
||||
CSP_SCRIPT_SRC=$(echo $CSP_SCRIPT_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
|
||||
|
||||
# upgrade-insecure-requests tells the browser to rewrite every http subresource,
|
||||
# fetch and WebSocket to https/wss. That is correct for https deployments but breaks
|
||||
# plain-http ones (e.g. local or self-hosted over http), where it would rewrite the
|
||||
# working http/ws endpoints to unreachable https/wss. Emit it only when the backend
|
||||
# is served over https, matching the ws:// vs wss:// scheme logic above.
|
||||
CSP_UPGRADE_INSECURE=" upgrade-insecure-requests;"
|
||||
if [[ "$NETBIRD_MGMT_API_ENDPOINT" == http://* || "$AUTH_AUTHORITY" == http://* ]]; then
|
||||
CSP_UPGRADE_INSECURE=""
|
||||
fi
|
||||
|
||||
# Update CSP in nginx config
|
||||
CSP_POLICY="default-src 'none'; connect-src 'self' $CSP_CONNECT_SRC; frame-src 'self' $CSP_FRAME_SRC; script-src 'self' 'wasm-unsafe-eval' $CSP_SCRIPT_SRC; font-src 'self'; img-src * data:; manifest-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'; base-uri 'self'; form-action 'self';$CSP_UPGRADE_INSECURE"
|
||||
CSP_POLICY="default-src 'none'; connect-src 'self' $CSP_CONNECT_SRC; frame-src 'self' $CSP_FRAME_SRC; script-src 'self' 'wasm-unsafe-eval' $CSP_SCRIPT_SRC; font-src 'self'; img-src * data:; manifest-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests;"
|
||||
CSP_HEADER="add_header Content-Security-Policy \"$CSP_POLICY\" always;"
|
||||
|
||||
echo "CSP header: $CSP_HEADER"
|
||||
@@ -163,7 +149,7 @@ sed -i "s|add_header Content-Security-Policy \"[^\"]*\" always;|$CSP_HEADER|g" /
|
||||
}
|
||||
|
||||
# replace ENVs in the config
|
||||
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_AUTH_SERVICE_URL \$\$NETBIRD_WASM_PATH \$\$NETBIRD_LICENSED \$\$NETBIRD_CLOUD \$\$NETBIRD_AGENT_NETWORK_ONLY \$\$NETBIRD_AGENT_NETWORK_ENABLED \$\$NETBIRD_HUBSPOT_PORTAL_ID \$\$NETBIRD_HUBSPOT_SIGNUP_FORM_ID \$\$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID \$\$NETBIRD_HUBSPOT_SURVEY_FORM_ID \$\$NETBIRD_ANALYTICS_EXCLUDED_EMAILS"
|
||||
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_AUTH_SERVICE_URL \$\$NETBIRD_WASM_PATH \$\$NETBIRD_LICENSED \$\$NETBIRD_CLOUD \$\$NETBIRD_HUBSPOT_PORTAL_ID \$\$NETBIRD_HUBSPOT_SIGNUP_FORM_ID \$\$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID \$\$NETBIRD_HUBSPOT_SURVEY_FORM_ID \$\$NETBIRD_ANALYTICS_EXCLUDED_EMAILS"
|
||||
|
||||
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
|
||||
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"
|
||||
@@ -171,25 +157,4 @@ for f in $(grep -R -l AUTH_SUPPORTED_SCOPES /usr/share/nginx/html); do
|
||||
cp "$f" "$f".copy
|
||||
envsubst "$ENV_STR" < "$f".copy > "$f"
|
||||
rm "$f".copy
|
||||
done
|
||||
|
||||
# Reload nginx so the patched CSP header takes effect.
|
||||
# supervisord starts nginx (priority 100) before this script (priority 201) and
|
||||
# never reloads it, so without this nginx keeps serving the static default.conf
|
||||
# CSP that has no connect-src, breaking OIDC discovery over plain HTTP.
|
||||
reloaded=false
|
||||
for i in $(seq 1 10); do
|
||||
if nginx -s reload 2>/dev/null; then
|
||||
echo "Reloaded nginx to apply updated CSP configuration"
|
||||
reloaded=true
|
||||
break
|
||||
fi
|
||||
echo "Waiting for nginx to be ready before reload (attempt $i)..."
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [[ "$reloaded" != true ]]; then
|
||||
echo "Failed to reload nginx after patching CSP header" >&2
|
||||
nginx -t || true
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
@@ -218,7 +218,6 @@ await visitByNavigation(page, "Policies"); // Click child
|
||||
| Notifications | `settings-notifications-email.spec.ts`, `settings-notifications-slack.spec.ts`, `settings-notifications-webhook.spec.ts` | `@notifications` |
|
||||
| Team | `team-users.spec.ts`, `team-service-users.spec.ts`, `team-users-approval-and-billing.spec.ts` | `@team` |
|
||||
| Setup Keys | `setup-keys.spec.ts` | `@setup-keys` |
|
||||
| Control Center | `control-center-draft.spec.ts`, `control-center-draft-connections.spec.ts`, `control-center-draft-matrix.spec.ts` | `@control-center` |
|
||||
|
||||
## Debugging
|
||||
|
||||
|
||||
@@ -89,25 +89,11 @@ async function apiDelete(page: Page, path: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function apiPost<T>(page: Page, path: string, data: unknown): Promise<T> {
|
||||
const { token, origin } = await getApiContext(page);
|
||||
const resp = await page.request.post(`${origin}/api${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data,
|
||||
});
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/** List all groups. */
|
||||
export async function listGroups(page: Page): Promise<Group[]> {
|
||||
return apiGet<Group[]>(page, "/groups");
|
||||
}
|
||||
|
||||
/** Create a group directly via the API (test setup). */
|
||||
export async function createGroup(page: Page, name: string): Promise<Group> {
|
||||
return apiPost<Group>(page, "/groups", { name, peers: [] });
|
||||
}
|
||||
|
||||
/** Delete a group by ID. */
|
||||
export async function deleteGroup(page: Page, groupId: string) {
|
||||
await apiDelete(page, `/groups/${groupId}`);
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import { Page, Locator, expect } from "@playwright/test";
|
||||
import { navigateTo } from "./auth";
|
||||
|
||||
export const CHANGES_KEY = "netbird-control-center-draft-changes";
|
||||
export const CANVAS_KEY = "netbird-control-center-draft-canvas";
|
||||
|
||||
export async function openControlCenter(page: Page) {
|
||||
await navigateTo(page, "/control-center");
|
||||
// The canvas hides behind cc-prefit until the first fitView; wait for the
|
||||
// pane itself so later mouse coordinates are meaningful.
|
||||
await expect(page.locator(".react-flow__pane")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function enterDraft(page: Page) {
|
||||
await page.getByTestId("cc-mode-draft").click();
|
||||
// The toolbar slides in with a spring animation — waiting for the Add
|
||||
// button also guarantees draft mode is fully active.
|
||||
await expect(page.getByTestId("cc-toolbar-add")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function exitDraftDiscarding(page: Page) {
|
||||
const draftTab = page.getByTestId("cc-mode-draft");
|
||||
if ((await draftTab.getAttribute("data-state")) !== "active") return;
|
||||
await page.getByTestId("cc-mode-live").click();
|
||||
const confirm = page.getByTestId("confirmation.confirm");
|
||||
if (await confirm.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await confirm.click();
|
||||
}
|
||||
await expect(page.getByTestId("cc-toolbar-add")).not.toBeVisible();
|
||||
}
|
||||
|
||||
/** Clears draft localStorage and makes sure we're on a clean live canvas. */
|
||||
export async function resetDraftState(page: Page) {
|
||||
await openControlCenter(page);
|
||||
await page.evaluate(
|
||||
([changes, canvas]) => {
|
||||
localStorage.removeItem(changes);
|
||||
localStorage.removeItem(canvas);
|
||||
},
|
||||
[CHANGES_KEY, CANVAS_KEY],
|
||||
);
|
||||
await exitDraftDiscarding(page);
|
||||
}
|
||||
|
||||
export async function readDraftChanges(page: Page): Promise<any[]> {
|
||||
const raw = await page.evaluate(
|
||||
(key) => localStorage.getItem(key),
|
||||
CHANGES_KEY,
|
||||
);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
}
|
||||
|
||||
async function centerOf(locator: Locator) {
|
||||
const box = await locator.boundingBox();
|
||||
if (!box) throw new Error("element has no layout");
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
}
|
||||
|
||||
async function mouseDrag(
|
||||
page: Page,
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
) {
|
||||
await page.mouse.move(from.x, from.y);
|
||||
await page.mouse.down();
|
||||
// Intermediate moves so drag thresholds and drop-target detection see a
|
||||
// realistic pointer path.
|
||||
const steps = 12;
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await page.mouse.move(
|
||||
from.x + ((to.x - from.x) * i) / steps,
|
||||
from.y + ((to.y - from.y) * i) / steps,
|
||||
);
|
||||
}
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drags a components-panel template onto the canvas. The panel uses a custom
|
||||
* pointer-based drag (not HTML5 DnD), so raw mouse events are required.
|
||||
*/
|
||||
export async function dragTemplateToCanvas(
|
||||
page: Page,
|
||||
templateTestId: string,
|
||||
target?: { x: number; y: number },
|
||||
) {
|
||||
const item = page.getByTestId(templateTestId);
|
||||
if (!(await item.isVisible().catch(() => false))) {
|
||||
await page.getByTestId("cc-toolbar-add").click();
|
||||
await expect(item).toBeVisible();
|
||||
}
|
||||
const pane = page.locator(".react-flow__pane");
|
||||
const paneBox = await pane.boundingBox();
|
||||
if (!paneBox) throw new Error("canvas pane not laid out");
|
||||
const to = target ?? {
|
||||
// Land right of the panel, roughly canvas center.
|
||||
x: paneBox.x + paneBox.width * 0.6,
|
||||
y: paneBox.y + paneBox.height * 0.5,
|
||||
};
|
||||
await mouseDrag(page, await centerOf(item), to);
|
||||
// Close the panel so it doesn't cover nodes for the next interaction.
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export function canvasNode(page: Page, dataIdPrefix: string) {
|
||||
return page.locator(`.react-flow__node[data-id^="${dataIdPrefix}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects two canvas nodes by dragging from a connect handle onto the target
|
||||
* node (FullAreaTargetHandle spans the whole node). Handle side matters:
|
||||
* dragging from `sr` (right) puts the source node on a policy's SOURCE side,
|
||||
* from `sl` (left) on the DESTINATION side. Resources only have `sl`.
|
||||
*/
|
||||
export async function connectNodes(
|
||||
page: Page,
|
||||
source: Locator,
|
||||
target: Locator,
|
||||
handleSide: "sr" | "sl" = "sr",
|
||||
) {
|
||||
// Connect handles fade in on node hover.
|
||||
await source.hover();
|
||||
const handle = source.locator(
|
||||
`.react-flow__handle[data-handleid="${handleSide}-connect"]`,
|
||||
);
|
||||
await expect(handle).toBeAttached();
|
||||
await mouseDrag(page, await centerOf(handle), await centerOf(target));
|
||||
}
|
||||
|
||||
/** Positions relative to the canvas pane (fractions of width/height). */
|
||||
export async function panePoint(page: Page, fx: number, fy: number) {
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("canvas pane not laid out");
|
||||
return { x: pane.x + pane.width * fx, y: pane.y + pane.height * fy };
|
||||
}
|
||||
|
||||
/** Right-clicks empty canvas and clicks a creation item ("New Group", …). */
|
||||
export async function createViaCanvasMenu(
|
||||
page: Page,
|
||||
itemText: string,
|
||||
at?: { fx: number; fy: number },
|
||||
) {
|
||||
const point = await panePoint(page, at?.fx ?? 0.6, at?.fy ?? 0.5);
|
||||
await page.mouse.move(point.x, point.y);
|
||||
await page.mouse.click(point.x, point.y, { button: "right" });
|
||||
const menu = page.getByTestId("cc-canvas-context-menu");
|
||||
await expect(menu).toBeVisible();
|
||||
await menu.getByRole("button", { name: itemText }).click();
|
||||
}
|
||||
|
||||
export async function readDraftCanvas(page: Page): Promise<any | null> {
|
||||
const raw = await page.evaluate(
|
||||
(key) => localStorage.getItem(key),
|
||||
CANVAS_KEY,
|
||||
);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
}
|
||||
|
||||
/** Drags one canvas node onto another (e.g. a peer into a group). */
|
||||
export async function dragNodeOnto(page: Page, node: Locator, target: Locator) {
|
||||
await mouseDrag(page, await centerOf(node), await centerOf(target));
|
||||
}
|
||||
|
||||
/** Right-clicks a node and clicks the given context-menu item. */
|
||||
export async function clickContextMenuItem(
|
||||
page: Page,
|
||||
node: Locator,
|
||||
itemText: string,
|
||||
) {
|
||||
await node.click({ button: "right" });
|
||||
const menu = page.getByTestId("cc-node-context-menu");
|
||||
await expect(menu).toBeVisible();
|
||||
await menu.getByRole("button", { name: itemText, exact: true }).click();
|
||||
}
|
||||
|
||||
export function reviewButton(page: Page) {
|
||||
return page.getByTestId("cc-draft-review");
|
||||
}
|
||||
|
||||
export async function expectChangeCount(page: Page, count: number) {
|
||||
if (count === 0) {
|
||||
await expect(reviewButton(page)).toBeDisabled();
|
||||
} else {
|
||||
await expect(reviewButton(page)).toBeEnabled();
|
||||
await expect(reviewButton(page)).toContainText(String(count));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the create-policy modal wizard (policy → posture checks → general)
|
||||
* and submits. Assumes both sides are already filled in.
|
||||
*/
|
||||
export async function submitCreatePolicyModal(page: Page, name?: string) {
|
||||
await page.getByTestId("policy-continue").click();
|
||||
await page.getByTestId("policy-continue").click();
|
||||
if (name) {
|
||||
await page.getByTestId("policy-name").fill(name);
|
||||
}
|
||||
await page.getByTestId("submit-policy").click();
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../helpers/fixtures";
|
||||
import { createGroup, deleteGroup, listGroups } from "../helpers/api";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import {
|
||||
canvasNode,
|
||||
clickContextMenuItem,
|
||||
connectNodes,
|
||||
dragNodeOnto,
|
||||
dragTemplateToCanvas,
|
||||
enterDraft,
|
||||
expectChangeCount,
|
||||
readDraftChanges,
|
||||
resetDraftState,
|
||||
reviewButton,
|
||||
submitCreatePolicyModal,
|
||||
} from "../helpers/control-center";
|
||||
|
||||
test.describe.serial(
|
||||
"Control Center Draft Connections @control-center",
|
||||
() => {
|
||||
test.beforeEach(async ({ dashboardAsOwner: page }) => {
|
||||
await resetDraftState(page);
|
||||
});
|
||||
|
||||
test("Should create a policy by connecting two groups", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
// Two groups, placed apart so the connect drag has a clear path.
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("no pane");
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.45,
|
||||
y: pane.y + pane.height * 0.35,
|
||||
});
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.75,
|
||||
y: pane.y + pane.height * 0.65,
|
||||
});
|
||||
const groups = canvasNode(page, "group-new-");
|
||||
await expect(groups).toHaveCount(2);
|
||||
await expectChangeCount(page, 2);
|
||||
|
||||
// Drag from the first group's right handle onto the second group —
|
||||
// the create-policy modal opens prefilled with both sides.
|
||||
await connectNodes(page, groups.nth(0), groups.nth(1));
|
||||
await expect(page.getByTestId("policy-name")).toBeVisible();
|
||||
await submitCreatePolicyModal(page);
|
||||
|
||||
// A complete policy lands on canvas with two edges and a tracked
|
||||
// create-policy change (2 groups + 1 policy = 3).
|
||||
await expect(canvasNode(page, "policy-new-")).toHaveCount(1);
|
||||
await expect(page.locator(".react-flow__edge")).toHaveCount(2);
|
||||
await expectChangeCount(page, 3);
|
||||
const changes = await readDraftChanges(page);
|
||||
expect(changes.filter((c) => c.type === "create-policy")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("Should keep a placeholder-peer policy out of the changeset until the peer installs", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("no pane");
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.45,
|
||||
y: pane.y + pane.height * 0.35,
|
||||
});
|
||||
await dragTemplateToCanvas(page, "cc-template-peer-user-device", {
|
||||
x: pane.x + pane.width * 0.75,
|
||||
y: pane.y + pane.height * 0.65,
|
||||
});
|
||||
const group = canvasNode(page, "group-new-").first();
|
||||
const peer = page.locator('.react-flow__node[data-id^="peer-"]');
|
||||
await expect(peer).toHaveCount(1);
|
||||
|
||||
await connectNodes(page, peer, group);
|
||||
await expect(page.getByTestId("policy-name")).toBeVisible();
|
||||
await submitCreatePolicyModal(page);
|
||||
|
||||
// The policy node and edges exist on canvas…
|
||||
await expect(canvasNode(page, "policy-new-")).toHaveCount(1);
|
||||
// …but a policy referencing an uninstalled placeholder peer is
|
||||
// incomplete: only the group create and the pending peer install are
|
||||
// tracked.
|
||||
const changes = await readDraftChanges(page);
|
||||
expect(changes.filter((c) => c.type === "create-policy")).toHaveLength(0);
|
||||
expect(changes.filter((c) => c.type === "install-peer")).toHaveLength(1);
|
||||
await expectChangeCount(page, 2);
|
||||
});
|
||||
|
||||
test("Should rename a group and propagate the name into the policy changeset", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("no pane");
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.45,
|
||||
y: pane.y + pane.height * 0.35,
|
||||
});
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.75,
|
||||
y: pane.y + pane.height * 0.65,
|
||||
});
|
||||
const groups = canvasNode(page, "group-new-");
|
||||
await connectNodes(page, groups.nth(0), groups.nth(1));
|
||||
await submitCreatePolicyModal(page);
|
||||
await expectChangeCount(page, 3);
|
||||
|
||||
// Rename the source group via its context menu.
|
||||
const sourceGroup = groups.nth(0);
|
||||
const newName = generateRandomName("cc-renamed-");
|
||||
await clickContextMenuItem(page, sourceGroup, "Rename");
|
||||
await page.getByTestId("cc-rename-input").fill(newName);
|
||||
await page.getByTestId("cc-rename-submit").click();
|
||||
|
||||
// The canvas node shows the new name…
|
||||
await expect(sourceGroup).toContainText(newName);
|
||||
// …the rename folded into the create-group entry (still 3 changes)…
|
||||
await expectChangeCount(page, 3);
|
||||
const changes = await readDraftChanges(page);
|
||||
expect(
|
||||
changes.some((c) => c.type === "create-group" && c.name === newName),
|
||||
).toBe(true);
|
||||
// …and the policy's group reference followed the rename.
|
||||
const policy = changes.find((c) => c.type === "create-policy");
|
||||
const rule = policy.policy.rules[0];
|
||||
const referenced = [...(rule.sources ?? []), ...(rule.destinations ?? [])]
|
||||
.map((g) => (typeof g === "string" ? g : g.name));
|
||||
expect(referenced).toContain(newName);
|
||||
|
||||
// Reopening the policy editor must show the renamed group SELECTED in
|
||||
// the group selector (the modal seeds from the canvas policy node).
|
||||
const policyNode = canvasNode(page, "policy-new-");
|
||||
await clickContextMenuItem(page, policyNode, "Edit");
|
||||
await expect(page.getByTestId("source-group-selector")).toContainText(
|
||||
newName,
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("Should create a group inline in the policy modal and show it on canvas", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("no pane");
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.45,
|
||||
y: pane.y + pane.height * 0.35,
|
||||
});
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.75,
|
||||
y: pane.y + pane.height * 0.65,
|
||||
});
|
||||
const groups = canvasNode(page, "group-new-");
|
||||
await connectNodes(page, groups.nth(0), groups.nth(1));
|
||||
await expect(page.getByTestId("policy-name")).toBeVisible();
|
||||
|
||||
// Type a brand-new group name into the destination selector and add it.
|
||||
const inlineName = generateRandomName("cc-inline-");
|
||||
await page.getByTestId("destination-group-selector").click();
|
||||
await page
|
||||
.getByTestId("destination-group-selector-search")
|
||||
.fill(inlineName);
|
||||
await page.keyboard.press("Enter");
|
||||
await page.keyboard.press("Escape"); // close the selector dropdown
|
||||
await submitCreatePolicyModal(page);
|
||||
|
||||
// The inline group exists on canvas as its own draft node and as a
|
||||
// create-group change.
|
||||
await expect(
|
||||
page.locator(".react-flow__node", { hasText: inlineName }),
|
||||
).toHaveCount(1);
|
||||
const changes = await readDraftChanges(page);
|
||||
expect(
|
||||
changes.some(
|
||||
(c) => c.type === "create-group" && c.name === inlineName,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("Should edit a draft policy and reflect the change on the canvas", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("no pane");
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.45,
|
||||
y: pane.y + pane.height * 0.35,
|
||||
});
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.75,
|
||||
y: pane.y + pane.height * 0.65,
|
||||
});
|
||||
const groups = canvasNode(page, "group-new-");
|
||||
await connectNodes(page, groups.nth(0), groups.nth(1));
|
||||
await submitCreatePolicyModal(page);
|
||||
const policyNode = canvasNode(page, "policy-new-");
|
||||
await expect(policyNode).toHaveCount(1);
|
||||
|
||||
// Edit via context menu, rename the policy on the General tab.
|
||||
const newName = generateRandomName("cc-policy-");
|
||||
await clickContextMenuItem(page, policyNode, "Edit");
|
||||
await expect(page.getByTestId("policy-name")).toBeVisible();
|
||||
await page.getByTestId("policy-name").fill(newName);
|
||||
await page.getByTestId("submit-policy").click();
|
||||
|
||||
// Canvas node text and the pending create-policy entry both updated.
|
||||
await expect(policyNode).toContainText(newName);
|
||||
const changes = await readDraftChanges(page);
|
||||
const policy = changes.find((c) => c.type === "create-policy");
|
||||
expect(policy.policy.name).toBe(newName);
|
||||
});
|
||||
|
||||
test("Should remove a draft policy from the canvas without a confirmation", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("no pane");
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.45,
|
||||
y: pane.y + pane.height * 0.35,
|
||||
});
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.75,
|
||||
y: pane.y + pane.height * 0.65,
|
||||
});
|
||||
const groups = canvasNode(page, "group-new-");
|
||||
await connectNodes(page, groups.nth(0), groups.nth(1));
|
||||
await submitCreatePolicyModal(page);
|
||||
await expectChangeCount(page, 3);
|
||||
|
||||
// Remove is canvas-only: no confirm dialog, pending create cancelled,
|
||||
// endpoint groups stay on canvas.
|
||||
const policyNode = canvasNode(page, "policy-new-");
|
||||
await clickContextMenuItem(page, policyNode, "Remove");
|
||||
await expect(page.getByTestId("confirmation.confirm")).not.toBeVisible();
|
||||
await expect(policyNode).toHaveCount(0);
|
||||
await expect(page.locator(".react-flow__edge")).toHaveCount(0);
|
||||
await expect(groups).toHaveCount(2);
|
||||
await expectChangeCount(page, 2);
|
||||
});
|
||||
|
||||
test("Should drop a peer into a group and track the membership", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
const pane = await page.locator(".react-flow__pane").boundingBox();
|
||||
if (!pane) throw new Error("no pane");
|
||||
await dragTemplateToCanvas(page, "cc-template-group", {
|
||||
x: pane.x + pane.width * 0.45,
|
||||
y: pane.y + pane.height * 0.35,
|
||||
});
|
||||
await dragTemplateToCanvas(page, "cc-template-peer-user-device", {
|
||||
x: pane.x + pane.width * 0.75,
|
||||
y: pane.y + pane.height * 0.65,
|
||||
});
|
||||
const group = canvasNode(page, "group-new-").first();
|
||||
const peer = page.locator('.react-flow__node[data-id^="peer-"]');
|
||||
await expect(peer).toHaveCount(1);
|
||||
|
||||
// Dragging the peer NODE onto the group absorbs it as a member: the
|
||||
// peer node leaves the canvas and the group's counter increments.
|
||||
await dragNodeOnto(page, peer, group);
|
||||
await expect(peer).toHaveCount(0);
|
||||
await expect(group).toContainText(/1\s*peer/i);
|
||||
|
||||
// The membership folded into the group's create entry.
|
||||
const changes = await readDraftChanges(page);
|
||||
const createGroupChange = changes.find(
|
||||
(c) => c.type === "create-group",
|
||||
);
|
||||
expect(createGroupChange.peerIds).toHaveLength(1);
|
||||
// group create + the peer's pending install step
|
||||
await expectChangeCount(page, 2);
|
||||
|
||||
// The group's Details panel lists the dropped (placeholder) peer even
|
||||
// though it isn't in the API peer list.
|
||||
await clickContextMenuItem(page, group, "Details");
|
||||
await expect(
|
||||
page.getByText("User Device", { exact: false }).last(),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("Should delete an existing group in draft and deploy the deletion", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
// Seed a real group via the API, then reload so the dashboard and the
|
||||
// components panel know it.
|
||||
const name = generateRandomName("cc-delete-");
|
||||
const seeded = await createGroup(page, name);
|
||||
await page.reload();
|
||||
await expect(page.locator(".react-flow__pane")).toBeVisible();
|
||||
await enterDraft(page);
|
||||
|
||||
// Pull the existing group onto the canvas from the components panel
|
||||
// (search narrows the virtualized list so the row is rendered).
|
||||
await page.getByTestId("cc-toolbar-add").click();
|
||||
await page.getByPlaceholder(/Search components/).fill(name);
|
||||
await dragTemplateToCanvas(page, `cc-panel-group-${seeded.id}`);
|
||||
|
||||
const groupNode = page.locator(
|
||||
`.react-flow__node[data-id="group-${seeded.id}"]`,
|
||||
);
|
||||
await expect(groupNode).toHaveCount(1);
|
||||
|
||||
// Delete (not Remove) an existing group: confirmed, tracked, deployed.
|
||||
await clickContextMenuItem(page, groupNode, "Delete");
|
||||
await expect(
|
||||
page.getByText(`Delete group "${name}"?`),
|
||||
).toBeVisible();
|
||||
await page.getByTestId("confirmation.confirm").click();
|
||||
await expect(groupNode).toHaveCount(0);
|
||||
await expectChangeCount(page, 1);
|
||||
|
||||
await reviewButton(page).click();
|
||||
const deleteResponse = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes(`/api/groups/${seeded.id}`) &&
|
||||
resp.request().method() === "DELETE",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("cc-deploy").click({ force: true });
|
||||
const response = await deleteResponse;
|
||||
expect(response.status()).toBeLessThan(300);
|
||||
|
||||
const remaining = await listGroups(page);
|
||||
expect(remaining.some((g) => g.id === seeded.id)).toBe(false);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
// Safety net: clear any seeded groups left behind by failed runs.
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto("/peers");
|
||||
const groups = await listGroups(page).catch(() => []);
|
||||
for (const g of groups) {
|
||||
if (g.name.startsWith("cc-delete-") || g.name.startsWith("cc-inline-")) {
|
||||
await deleteGroup(page, g.id).catch(() => {});
|
||||
}
|
||||
}
|
||||
await context.close();
|
||||
});
|
||||
},
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,156 +0,0 @@
|
||||
import { Page, expect } from "@playwright/test";
|
||||
import { test } from "../helpers/fixtures";
|
||||
import { deleteGroup, listGroups } from "../helpers/api";
|
||||
import {
|
||||
CHANGES_KEY,
|
||||
CANVAS_KEY,
|
||||
canvasNode,
|
||||
dragTemplateToCanvas,
|
||||
enterDraft,
|
||||
expectChangeCount,
|
||||
resetDraftState,
|
||||
reviewButton,
|
||||
} from "../helpers/control-center";
|
||||
|
||||
test.describe.serial("Control Center Draft Mode @control-center", () => {
|
||||
test.beforeEach(async ({ dashboardAsOwner: page }) => {
|
||||
// Every test starts from a clean live view: no leftover draft storage
|
||||
// from a previous (possibly failed) test.
|
||||
await resetDraftState(page);
|
||||
});
|
||||
|
||||
test("Should enter and leave draft mode without changes", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
await expect(page.getByTestId("cc-draft-cancel")).toBeVisible();
|
||||
await expectChangeCount(page, 0);
|
||||
|
||||
// No pending changes — switching back to live must not ask to confirm.
|
||||
await page.getByTestId("cc-mode-live").click();
|
||||
await expect(page.getByTestId("confirmation.confirm")).not.toBeVisible();
|
||||
await expect(page.getByTestId("cc-toolbar-add")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("Should add a draft group via drag and drop and track the change", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
await dragTemplateToCanvas(page, "cc-template-group");
|
||||
|
||||
await expect(canvasNode(page, "group-new-")).toHaveCount(1);
|
||||
await expectChangeCount(page, 1);
|
||||
|
||||
// The changeset is persisted to localStorage on every update.
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((key) => localStorage.getItem(key), CHANGES_KEY),
|
||||
)
|
||||
.toContain("create-group");
|
||||
});
|
||||
|
||||
test("Should undo and redo the dropped group", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
await dragTemplateToCanvas(page, "cc-template-group");
|
||||
const groupNode = canvasNode(page, "group-new-");
|
||||
await expect(groupNode).toHaveCount(1);
|
||||
|
||||
// History snapshots are debounced (300ms) — wait for Undo to arm
|
||||
// instead of clicking immediately.
|
||||
const undo = page.getByTestId("cc-toolbar-undo");
|
||||
await expect(undo).toBeEnabled();
|
||||
await undo.click();
|
||||
await expect(groupNode).toHaveCount(0);
|
||||
await expectChangeCount(page, 0);
|
||||
|
||||
const redo = page.getByTestId("cc-toolbar-redo");
|
||||
await expect(redo).toBeEnabled();
|
||||
await redo.click();
|
||||
await expect(groupNode).toHaveCount(1);
|
||||
await expectChangeCount(page, 1);
|
||||
});
|
||||
|
||||
test("Should restore the draft from storage after a reload", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
await dragTemplateToCanvas(page, "cc-template-group");
|
||||
await expectChangeCount(page, 1);
|
||||
// Wait for the canvas snapshot to land in storage before reloading.
|
||||
await expect
|
||||
.poll(() => page.evaluate((key) => localStorage.getItem(key), CANVAS_KEY))
|
||||
.toContain("group-new-");
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator(".react-flow__pane")).toBeVisible();
|
||||
// A reload returns to live; re-entering draft restores the snapshot
|
||||
// instead of rebuilding from the live view.
|
||||
await enterDraft(page);
|
||||
await expect(canvasNode(page, "group-new-")).toHaveCount(1);
|
||||
await expectChangeCount(page, 1);
|
||||
});
|
||||
|
||||
test("Should confirm before discarding draft changes", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
await dragTemplateToCanvas(page, "cc-template-group");
|
||||
await expectChangeCount(page, 1);
|
||||
|
||||
// Cancelling the confirmation keeps the draft.
|
||||
await page.getByTestId("cc-draft-cancel").click();
|
||||
await expect(page.getByText("Discard draft changes?")).toBeVisible();
|
||||
await page.getByTestId("confirmation.cancel").click();
|
||||
await expect(page.getByTestId("cc-toolbar-add")).toBeVisible();
|
||||
await expectChangeCount(page, 1);
|
||||
|
||||
// Confirming discards and returns to live with storage cleared.
|
||||
await page.getByTestId("cc-draft-cancel").click();
|
||||
await page.getByTestId("confirmation.confirm").click();
|
||||
await expect(page.getByTestId("cc-toolbar-add")).not.toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((key) => localStorage.getItem(key), CHANGES_KEY),
|
||||
)
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
test("Should deploy a created group to the live account", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await enterDraft(page);
|
||||
await dragTemplateToCanvas(page, "cc-template-group");
|
||||
await expectChangeCount(page, 1);
|
||||
|
||||
await reviewButton(page).click();
|
||||
await expect(page.getByText("Review & Deploy")).toBeVisible();
|
||||
// The single pending change is a group create.
|
||||
await expect(page.getByText("Create", { exact: true })).toBeVisible();
|
||||
|
||||
const createResponse = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/api/groups") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("cc-deploy").click({ force: true });
|
||||
const response = await createResponse;
|
||||
expect([200, 201]).toContain(response.status());
|
||||
const created = await response.json();
|
||||
|
||||
// Deploy exits the draft back to the rebuilt live view.
|
||||
await expect(page.getByTestId("cc-toolbar-add")).not.toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((key) => localStorage.getItem(key), CHANGES_KEY),
|
||||
)
|
||||
.toBeNull();
|
||||
|
||||
// The group really exists in the account — then clean it up.
|
||||
const groups = await listGroups(page);
|
||||
expect(groups.some((g) => g.id === created.id)).toBe(true);
|
||||
await deleteGroup(page, created.id);
|
||||
});
|
||||
});
|
||||
Generated
+46
-1914
File diff suppressed because it is too large.
Load diff
+2
-8
@@ -18,8 +18,6 @@
|
||||
"test:clean": "cd ./e2e/environment && sh clean-test-env.sh",
|
||||
"test:dev": "cross-env APP_ENV=test next dev -p 1337",
|
||||
"test": "npx playwright test --config=e2e/playwright.config.ts",
|
||||
"test:unit": "vitest run",
|
||||
"test:unit:watch": "vitest",
|
||||
"test:ui": "npx playwright test --config=e2e/playwright.config.ts --ui",
|
||||
"test:ci": "cross-env APP_ENV=test next build && npx playwright test --config=e2e/playwright.config.ts"
|
||||
},
|
||||
@@ -105,17 +103,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^9.5.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/chroma-js": "^3.1.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8",
|
||||
"prettier": "3.0.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vitest": "^4.1.10"
|
||||
"tailwindcss": "^3.4.17"
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,8 @@ export default function AccessControlPage() {
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Access Control Policies</h1>
|
||||
<Paragraph>
|
||||
Policies connect users and agents to your network resources,
|
||||
controlling what each identity can reach.{" "}
|
||||
Create rules to manage access in your network and define what peers
|
||||
can connect.{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-network-access"}
|
||||
target={"_blank"}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { VerticalTabs } from "@components/VerticalTabs";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { Gauge, ScrollText } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import React, { Suspense, useEffect, useState } from "react";
|
||||
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
|
||||
import GroupsProvider from "@/contexts/GroupsProvider";
|
||||
import PeersProvider from "@/contexts/PeersProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import AgentAccountControlsCard from "@/modules/agent-network/AgentAccountControlsCard";
|
||||
import AgentBudgetRulesTable from "@/modules/agent-network/AgentBudgetRulesTable";
|
||||
import AIProvidersProvider from "@/modules/agent-network/AIProvidersProvider";
|
||||
|
||||
const TAB_BUDGET_SETTINGS = "budget-settings";
|
||||
const TAB_LOG_SETTINGS = "log-settings";
|
||||
|
||||
// AgentNetworkConfigurationPage holds the configuration surfaces extracted from
|
||||
// Usage & Logs — global limits and log-collection controls — using the same
|
||||
// vertical-tab layout (and per-tab breadcrumb/heading/description) as the main
|
||||
// Settings page so it reads as "settings for the Agent Network".
|
||||
export default function AgentNetworkConfigurationPage() {
|
||||
const { permission } = usePermissions();
|
||||
const queryParams = useSearchParams();
|
||||
const queryTab = queryParams.get("tab");
|
||||
const [tab, setTab] = useState(queryTab ?? TAB_BUDGET_SETTINGS);
|
||||
|
||||
useEffect(() => {
|
||||
if (queryTab) setTab(queryTab);
|
||||
}, [queryTab]);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<VerticalTabs value={tab} onChange={setTab}>
|
||||
<VerticalTabs.List>
|
||||
<VerticalTabs.Trigger value={TAB_BUDGET_SETTINGS}>
|
||||
<Gauge size={14} />
|
||||
Global Limits
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value={TAB_LOG_SETTINGS}>
|
||||
<ScrollText size={14} />
|
||||
Log Collection
|
||||
</VerticalTabs.Trigger>
|
||||
</VerticalTabs.List>
|
||||
<RestrictedAccess
|
||||
page={"Configuration"}
|
||||
hasAccess={permission?.services?.read}
|
||||
>
|
||||
<GroupsProvider>
|
||||
<PeersProvider>
|
||||
<AIProvidersProvider>
|
||||
<div className={"border-l border-nb-gray-930 w-full"}>
|
||||
<Tabs.Content
|
||||
value={TAB_BUDGET_SETTINGS}
|
||||
className={"w-full"}
|
||||
>
|
||||
<ConfigTabHeader
|
||||
label={"Global Limits"}
|
||||
href={"/agent-network/configuration?tab=budget-settings"}
|
||||
>
|
||||
Account-wide caps on token usage and spend, applied across
|
||||
every policy. Scope a limit to specific groups or users,
|
||||
or leave it account-wide.
|
||||
</ConfigTabHeader>
|
||||
{/* DataTable applies its own p-default, so it is rendered
|
||||
directly (no extra wrapper) to align with the header. */}
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<AgentBudgetRulesTable />
|
||||
</Suspense>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value={TAB_LOG_SETTINGS} className={"w-full"}>
|
||||
{/* Self-contained tab (own breadcrumb + heading + save),
|
||||
mirroring the Settings > Authentication layout. */}
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<AgentAccountControlsCard />
|
||||
</Suspense>
|
||||
</Tabs.Content>
|
||||
</div>
|
||||
</AIProvidersProvider>
|
||||
</PeersProvider>
|
||||
</GroupsProvider>
|
||||
</RestrictedAccess>
|
||||
</VerticalTabs>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ConfigTabHeader mirrors the breadcrumb + heading + description block each
|
||||
// Settings tab renders at the top of its content.
|
||||
function ConfigTabHeader({
|
||||
label,
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
href: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/agent-network/providers"}
|
||||
label={"Agent Network"}
|
||||
icon={<AgentNetworkIcon size={16} />}
|
||||
/>
|
||||
<Breadcrumbs.Item href={href} label={label} active />
|
||||
</Breadcrumbs>
|
||||
<h1>{label}</h1>
|
||||
{children && <Paragraph>{children}</Paragraph>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { isAgentNetworkEnabled } from "@utils/netbird";
|
||||
import { notFound } from "next/navigation";
|
||||
import * as React from "react";
|
||||
|
||||
// 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.
|
||||
export default function AgentNetworkLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (!isAgentNetworkEnabled()) {
|
||||
notFound();
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { Suspense } from "react";
|
||||
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import AgentPoliciesTable from "@/modules/agent-network/AgentPoliciesTable";
|
||||
import AIProvidersProvider from "@/modules/agent-network/AIProvidersProvider";
|
||||
|
||||
export default function AgentNetworkPoliciesPage() {
|
||||
const { permission } = usePermissions();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/agent-network/providers"}
|
||||
label={"Agent Network"}
|
||||
icon={<AgentNetworkIcon size={16} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/agent-network/policies"}
|
||||
label={"Policies"}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Policies</h1>
|
||||
<Paragraph>
|
||||
Bind IdP groups to providers: Engineering gets Claude, Finance
|
||||
doesn't. Enforce token limits, budgets, and guardrails.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess
|
||||
page={"Policies"}
|
||||
hasAccess={permission?.services?.read}
|
||||
>
|
||||
<AIProvidersProvider>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<AgentPoliciesTable headingTarget={portalTarget} />
|
||||
</Suspense>
|
||||
</AIProvidersProvider>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import { HelpTooltip } from "@components/HelpTooltip";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useCopyToClipboard from "@hooks/useCopyToClipboard";
|
||||
import { Copy, ExternalLinkIcon, Globe, Plug } from "lucide-react";
|
||||
import React, { Suspense, useState } from "react";
|
||||
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import AgentConnectModal from "@/modules/agent-network/AgentConnectModal";
|
||||
import AIProviderModal from "@/modules/agent-network/AIProviderModal";
|
||||
import AIProvidersProvider, {
|
||||
useAIProviders,
|
||||
} from "@/modules/agent-network/AIProvidersProvider";
|
||||
import AgentProvidersTable from "@/modules/agent-network/table/AgentProvidersTable";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
|
||||
function EndpointBadge({ endpoint }: { endpoint: string }) {
|
||||
const [, copy] = useCopyToClipboard(`https://${endpoint}`);
|
||||
const [connectOpen, setConnectOpen] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"inline-flex items-center gap-3 rounded-lg border border-nb-gray-800 bg-nb-gray-900/40 p-3 min-w-[300px]"
|
||||
}
|
||||
>
|
||||
<div className={"flex flex-col"}>
|
||||
<div
|
||||
className={
|
||||
"text-[10px] text-nb-gray-400 uppercase tracking-wider font-medium inline-flex items-center gap-1.5"
|
||||
}
|
||||
>
|
||||
API Base URL
|
||||
<HelpTooltip
|
||||
iconSize={11}
|
||||
content={
|
||||
<>
|
||||
Use this URL as the base URL when configuring your AI agents or
|
||||
LLM SDK clients (e.g. OpenAI's
|
||||
<code className={"font-mono"}> base_url</code>, Anthropic's{" "}
|
||||
<code className={"font-mono"}>baseURL</code>, or any HTTP
|
||||
client). Calls hit NetBird first, get authorised by your
|
||||
policies, and only then reach the upstream provider.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<code
|
||||
className={
|
||||
"font-mono text-xs text-nb-gray-100 leading-tight mt-0.5 whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
https://{endpoint}
|
||||
</code>
|
||||
</div>
|
||||
<button
|
||||
type={"button"}
|
||||
className={
|
||||
"inline-flex items-center gap-1.5 rounded-md border border-nb-gray-700 bg-nb-gray-800/60 px-2.5 py-1.5 text-[11px] font-medium text-nb-gray-200 hover:bg-nb-gray-800 hover:text-white transition-colors shrink-0"
|
||||
}
|
||||
onClick={() => copy("Endpoint copied to clipboard")}
|
||||
aria-label={"Copy endpoint"}
|
||||
>
|
||||
<Copy size={12} />
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
type={"button"}
|
||||
className={
|
||||
"inline-flex items-center gap-1.5 rounded-md border border-nb-gray-700 bg-nb-gray-800/60 px-2.5 py-1.5 text-[11px] font-medium text-nb-gray-200 hover:bg-nb-gray-800 hover:text-white transition-colors shrink-0"
|
||||
}
|
||||
onClick={() => setConnectOpen(true)}
|
||||
aria-label={"Agent config"}
|
||||
>
|
||||
<Plug size={12} />
|
||||
Agent Config
|
||||
</button>
|
||||
<AgentConnectModal
|
||||
open={connectOpen}
|
||||
onOpenChange={setConnectOpen}
|
||||
endpoint={endpoint}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EndpointHeader() {
|
||||
const { settings, settingsLoading, openWizard } = useAIProviders();
|
||||
if (settingsLoading) return null;
|
||||
if (!settings) {
|
||||
return (
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={openWizard}
|
||||
className={
|
||||
"inline-flex items-center gap-3 rounded-lg border border-dashed border-nb-gray-800 bg-nb-gray-900/20 p-3 text-left hover:border-nb-gray-700 hover:bg-nb-gray-900/40 transition-colors cursor-pointer min-w-[300px]"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"h-8 w-8 rounded-md bg-nb-gray-900 flex items-center justify-center shrink-0"
|
||||
}
|
||||
>
|
||||
<Globe size={14} className={"text-nb-gray-500"} />
|
||||
</div>
|
||||
<div className={"flex flex-col min-w-0"}>
|
||||
<div
|
||||
className={
|
||||
"text-[10px] text-nb-gray-500 uppercase tracking-wider font-medium inline-flex items-center gap-1.5"
|
||||
}
|
||||
>
|
||||
API Base URL
|
||||
<span onClick={(e) => e.stopPropagation()}>
|
||||
<HelpTooltip
|
||||
iconSize={11}
|
||||
content={
|
||||
<>
|
||||
Use this URL as the base URL when configuring your AI agents
|
||||
or LLM SDK clients (e.g. OpenAI's
|
||||
<code className={"font-mono"}> base_url</code>,
|
||||
Anthropic's{" "}
|
||||
<code className={"font-mono"}>baseURL</code>, or any HTTP
|
||||
client). Calls hit NetBird first, get authorised by your
|
||||
policies, and only then reach the upstream provider.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<span className={"text-xs text-nb-gray-400 leading-tight mt-0.5"}>
|
||||
Connect your first provider to set up your agent network endpoint.
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return <EndpointBadge endpoint={settings.endpoint} />;
|
||||
}
|
||||
|
||||
function PageBody({
|
||||
headingTarget,
|
||||
}: {
|
||||
headingTarget: HTMLHeadingElement | null;
|
||||
}) {
|
||||
const { isWizardOpen, closeWizard } = useAIProviders();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<AgentProvidersTable headingTarget={headingTarget} />
|
||||
</Suspense>
|
||||
<AIProviderModal open={isWizardOpen} onOpenChange={closeWizard} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentNetworkProvidersPage() {
|
||||
const { permission } = usePermissions();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
{/* Gate the whole surface: AIProvidersProvider and EndpointHeader fetch
|
||||
agent-network state, so they must not mount for users without
|
||||
services.read. */}
|
||||
<RestrictedAccess
|
||||
page={"Providers"}
|
||||
hasAccess={permission?.services?.read}
|
||||
>
|
||||
<AIProvidersProvider>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/agent-network/providers"}
|
||||
label={"Agent Network"}
|
||||
icon={<AgentNetworkIcon size={16} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/agent-network/providers"}
|
||||
label={"Providers"}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Providers</h1>
|
||||
<Paragraph>
|
||||
Connect AI providers and gateways like LiteLLM, OpenAI, and
|
||||
Anthropic through one keyless endpoint, accessible only via
|
||||
NetBird’s tunnel.
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/agent-network/providers"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
<div className={"mt-4"}>
|
||||
<EndpointHeader />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PageBody headingTarget={portalTarget} />
|
||||
</AIProvidersProvider>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { LayoutDashboard, ScrollText } from "lucide-react";
|
||||
import dayjs from "dayjs";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
|
||||
import GroupsProvider from "@/contexts/GroupsProvider";
|
||||
import PeersProvider from "@/contexts/PeersProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import ServerPaginationProvider from "@/contexts/ServerPaginationProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import AgentAccessLogTable from "@/modules/agent-network/AgentAccessLogTable";
|
||||
import AgentOverviewPanel from "@/modules/agent-network/AgentOverviewPanel";
|
||||
import AIProvidersProvider from "@/modules/agent-network/AIProvidersProvider";
|
||||
|
||||
// Tab ids — kept stable so ?tab=<id> URL hand-off works (e.g.
|
||||
// /agent-network/usage?tab=access-logs), the same way Settings deep-links tabs.
|
||||
const TAB_USAGE = "usage";
|
||||
const TAB_ACCESS_LOGS = "access-logs";
|
||||
|
||||
const VALID_TABS = new Set([TAB_USAGE, TAB_ACCESS_LOGS]);
|
||||
|
||||
// UsageAndLogsPage surfaces the live access log and the spend dashboard.
|
||||
// Budget rules and log-collection controls live under the separate
|
||||
// Configuration entry. Providers are mounted once at the top so switching
|
||||
// tabs is instant — no re-fetch on tab change.
|
||||
export default function UsageAndLogsPage() {
|
||||
const { permission } = usePermissions();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const queryTab = searchParams.get("tab") ?? "";
|
||||
const initialTab = VALID_TABS.has(queryTab) ? queryTab : TAB_USAGE;
|
||||
const [tab, setTab] = useState(initialTab);
|
||||
|
||||
// Access-log view mode: flat per-request rows, or grouped by provider session.
|
||||
// Each mode hits its own endpoint and the provider is remounted on toggle
|
||||
// (keyed below) so the two response shapes never cross.
|
||||
const [groupBySession, setGroupBySession] = useState(false);
|
||||
|
||||
// Default the access-log view to the last 14 days. Computed once on mount so
|
||||
// the window is stable across re-renders; resetting filters returns here.
|
||||
const defaultAccessLogFilters = useMemo(
|
||||
() => ({
|
||||
start_date: dayjs().subtract(14, "day").startOf("day").toISOString(),
|
||||
end_date: dayjs().endOf("day").toISOString(),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Keep the tab in sync when the ?tab= query changes (deep links / back-forward).
|
||||
// Also reset to the default when ?tab= is removed or invalid, so navigating
|
||||
// back to the bare URL doesn't leave the previous tab selected.
|
||||
useEffect(() => {
|
||||
setTab(VALID_TABS.has(queryTab) ? queryTab : TAB_USAGE);
|
||||
}, [queryTab]);
|
||||
|
||||
// Reflect the active tab in the URL so it's shareable, like Settings.
|
||||
const onTabChange = (value: string) => {
|
||||
setTab(value);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("tab", value);
|
||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6 pb-0"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/agent-network/providers"}
|
||||
label={"Agent Network"}
|
||||
icon={<AgentNetworkIcon size={16} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/agent-network/usage"}
|
||||
label={"Usage & Logs"}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1>Usage & Logs</h1>
|
||||
<Paragraph>
|
||||
Per-request audit with real caller identity, cost attribution, and
|
||||
budget controls.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess
|
||||
page={"Usage & Logs"}
|
||||
hasAccess={permission?.services?.read}
|
||||
>
|
||||
<GroupsProvider>
|
||||
<PeersProvider>
|
||||
<AIProvidersProvider>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={onTabChange}
|
||||
defaultValue={initialTab}
|
||||
className={"pt-4 pb-0 mb-0"}
|
||||
>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
<TabsTrigger value={TAB_USAGE}>
|
||||
<LayoutDashboard size={16} />
|
||||
Usage
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={TAB_ACCESS_LOGS}>
|
||||
<ScrollText size={16} />
|
||||
Access Logs
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={TAB_USAGE} className={"pb-8"}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<AgentOverviewPanel />
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={TAB_ACCESS_LOGS} className={"pb-8"}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<ServerPaginationProvider
|
||||
key={groupBySession ? "sessions" : "flat"}
|
||||
url={
|
||||
groupBySession
|
||||
? "/agent-network/access-log-sessions"
|
||||
: "/agent-network/access-logs"
|
||||
}
|
||||
defaultPageSize={25}
|
||||
defaultFilters={defaultAccessLogFilters}
|
||||
>
|
||||
<AgentAccessLogTable
|
||||
grouped={groupBySession}
|
||||
onGroupedChange={setGroupBySession}
|
||||
/>
|
||||
</ServerPaginationProvider>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</AIProvidersProvider>
|
||||
</PeersProvider>
|
||||
</GroupsProvider>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -46,7 +46,6 @@ import {
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { toASCII } from "punycode";
|
||||
import { EditPeerNameModal } from "@/modules/peers/EditPeerNameModal";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { useSWRConfig } from "swr";
|
||||
@@ -275,7 +274,7 @@ const PeerHeader = () => {
|
||||
<PencilIcon size={16} />
|
||||
</div>
|
||||
</ModalTrigger>
|
||||
<EditPeerNameModal
|
||||
<EditNameModal
|
||||
onSuccess={(newName) => {
|
||||
updatePeer(newName).then(() => {
|
||||
setName(newName);
|
||||
@@ -740,6 +739,91 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
);
|
||||
}
|
||||
|
||||
interface ModalProps {
|
||||
onSuccess: (name: string) => void;
|
||||
peer: Peer;
|
||||
initialName: string;
|
||||
}
|
||||
|
||||
function EditNameModal({ onSuccess, peer, initialName }: Readonly<ModalProps>) {
|
||||
const [name, setName] = useState(initialName);
|
||||
|
||||
const isDisabled = useMemo(() => {
|
||||
if (name === peer.name) return true;
|
||||
const trimmedName = trim(name);
|
||||
return trimmedName.length === 0;
|
||||
}, [name, peer]);
|
||||
|
||||
const domainNamePreview = useMemo(() => {
|
||||
let punyName = toASCII(name.toLowerCase());
|
||||
punyName = punyName.replace(/[^a-z0-9]/g, "-");
|
||||
let domain = "";
|
||||
if (peer.dns_label) {
|
||||
const labelList = peer.dns_label.split(".");
|
||||
if (labelList.length > 1) {
|
||||
labelList.splice(0, 1);
|
||||
domain = "." + labelList.join(".");
|
||||
}
|
||||
}
|
||||
return punyName + domain;
|
||||
}, [name, peer]);
|
||||
|
||||
return (
|
||||
<ModalContent maxWidthClass={"max-w-md"}>
|
||||
<form>
|
||||
<ModalHeader
|
||||
title={"Edit Peer Name"}
|
||||
description={"Set an easily identifiable name for your peer."}
|
||||
color={"blue"}
|
||||
/>
|
||||
|
||||
<div className={"p-default flex flex-col gap-4"}>
|
||||
<div>
|
||||
<Input
|
||||
placeholder={"e.g., AWS Servers"}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Card className={"w-full px-6 pt-5 pb-4"}>
|
||||
<Label>
|
||||
<Globe size={15} />
|
||||
Domain Name Preview
|
||||
</Label>
|
||||
<HelpText className={"mt-2"}>
|
||||
If the domain name already exists, we add an increment number
|
||||
suffix to it.
|
||||
</HelpText>
|
||||
<div className={"text-netbird text-sm break-all whitespace-normal"}>
|
||||
{domainNamePreview}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<ModalFooter className={"items-center"} separator={false}>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"} className={"w-full"}>
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"w-full"}
|
||||
onClick={() => onSuccess(name)}
|
||||
disabled={isDisabled}
|
||||
type={"submit"}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableValue({
|
||||
value,
|
||||
canEdit,
|
||||
|
||||
Loaded 20 of 234 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user