Compare commits

..
Author SHA1 Message Date
Viktor Liu f34b3d8331 Handle a failed peer fetch, blocked storage and an unvalidated VNC port 2026-09-09 13:53:34 +02:00
Viktor Liu 107e326750 Reopen the VNC password prompt on a rejected password and cancel back to the form 2026-09-09 12:25:03 +02:00
Viktor Liu 3303c5de99 Drop the toolbar separator when nothing precedes End session 2026-09-09 12:14:47 +02:00
Viktor Liu a0d497239b Hide the VNC controls an external server cannot serve 2026-09-09 12:12:44 +02:00
Viktor Liu ce006c5f28 Fix VNC reconnect, error styling and the external connection messages 2026-09-09 12:11:17 +02:00
Viktor Liu 9e9b88d36c Render the RDP credentials error with the shared callout 2026-09-09 12:11:17 +02:00
Viktor Liu f1a1ec27cf Move the VNC connection choice into a modal and open access only once made 2026-09-09 11:37:13 +02:00
Viktor Liu 97bb3742ea Offer VNC on every system, gating only NetBird's own server by OS 2026-09-09 10:58:00 +02:00
Viktor Liu 881a9d7a83 Offer a VNC server already running on the peer alongside NetBird's 2026-09-09 10:31:22 +02:00
Viktor Liu a867170355 Keep the SSH IP version and disconnect message in sync with the dialed host 2026-09-07 18:47:21 +02:00
Viktor Liu fd13579e50 Shape the VNC viewer for a phone and drop its cursor toggle 2026-09-07 16:29:17 +02:00
Viktor Liu e6d5faf314 Derive VNC query params during render and type the IP version 2026-09-07 16:29:09 +02:00
Viktor Liu b13bce56d8 Sync the VNC toolbar drag ref from an effect, not during render 2026-09-07 16:29:09 +02:00
Viktor Liu e92ce98700 Keep the dot cursor off by default 2026-09-07 16:29:09 +02:00
Viktor Liu 63cb86957a Honour the dot-cursor setting, drop private noVNC calls and share the error toast 2026-09-07 16:29:08 +02:00
Viktor Liu ddf5d30045 Handle the paste gesture once, from the keydown only 2026-09-07 16:29:08 +02:00
Viktor Liu 5acf358ab8 Paste into the remote with Ctrl+V, not only the toolbar button 2026-09-07 16:29:08 +02:00
Viktor Liu 66f4eb4dd6 Expand the VNC toolbar on keyboard focus and show a focus ring 2026-09-07 16:29:08 +02:00
Viktor Liu c907fd9475 Add an End session button to the VNC toolbar 2026-09-07 16:29:08 +02:00
Viktor Liu a6780ea7ee Add embedded VNC viewer with noVNC integration 2026-09-07 16:29:08 +02:00
Viktor Liu 9a3fc271da Add SSH support for Android peers 2026-09-07 16:27:59 +02:00
Maycon Santos cba2613e68 Add anonymization level and upload URL to the debug bundle modal (#753)
Surfaces the two bundle parameters added to the management API: an
anonymization level (Default/Strict, shown when anonymization is enabled)
and an optional upload service URL that overrides the default upload
server. The upload URL is validated as an https URL before the job can be
submitted; both fields are omitted from the request when left at their
defaults so the peer applies its own.
2026-09-03 20:05:02 +02:00
Maycon Santos fe32179dc1 Pin the group view's group, and give agentgateway a reachable upstream (#788)
Stabilize the flaky Control Center and agentgateway e2e specs (#788)

  Three separate causes behind the recent e2e failures.

  The group view ranked every group in the account by peer count to decide what
  to display, so with two workers it could land on a group another spec had just
  created. openGroupView() selects the group by name instead.

  Management now checks a provider's upstream URL when the provider is saved and
  rejects it unless the URL returns a model list, so the agentgateway spec's
  placeholder host stopped working. The test environment runs a stub that answers
  the check.

  The Backspace test blurred the group panel's search input before that input had
  taken focus, so it reclaimed focus and swallowed the key.
2026-09-03 18:38:12 +02:00
Eduard Gert 1bad86b277 Show peer-sourced policies in the Control Center peer view (#786)
The peer view matched policies only by source group membership, so a
policy whose source is the peer itself never appeared in that peer's
view. There was no group fallback either: a rule carrying a
sourceResource has its sources nulled on save, so the group check could
only ever return false for exactly these policies.

Such policies are reachable from the canvas itself, since dragging a
connection off a peer node writes a sourceResource rather than a group.
A user could create a peer-sourced policy, deploy it, drill into that
same peer and not see it.

Match rule.sourceResource against the peer id alongside the group check.
The rendering side needs no change, as select-peer-node is the peer and
its edge to the policy is drawn unconditionally. Draft mode is fixed
transitively: useDraft harvests visible policies from the live view's
nodes, and already draws a sourceResource peer as a source card.
2026-09-03 14:29:06 +02:00
Maycon Santos 3c72444e83 Keep a refused provider save on screen, and its discovered prices (#772)
A refused save closed the modal anyway, throwing away the key just typed — and
the API never returns one. Both paths now stop before closing. The failure was
also reported twice and, because notify() only reddens through its promise path,
both times as a success.

Load and submit now spin, say what they are waiting on, and stop taking clicks;
dismissal is refused mid-save, since the write lands regardless and would
otherwise close a session reopened in the meantime.

Discovery carries the record id and the form's URL together, so changing an
endpoint no longer demands the key back. A 422 from it was reported as
unreachable rather than unsupported — useApiCall rejects with { code, message }
and the hook read status, leaving the catalog fallback unreachable.

Discovered models arrived at zero: 0/0 was hardcoded for anything the catalog
lacked by exact id, which stopped being true once discovery returned real rates.
Bedrock felt all of it — its geography-prefixed ids never match by string.

A Playwright spec covers the refusal end to end.
2026-09-02 21:47:47 +02:00
Nicolas Frati 386fb12013 fix(e2e): stabilize agentgateway provider test (#782) 2026-09-02 19:09:36 +02:00
Daneyon Hansen e3cc74522c feat(agent-network): add agentgateway provider support (#774)
* feat(agent-network): add agentgateway provider UI

Signed-off-by: Daneyon Hansen <daneyon.hansen@solo.io>

* fix(agent-network): address agentgateway review feedback

Signed-off-by: Daneyon Hansen <daneyon.hansen@solo.io>

* fix(agent-network): align with updated dashboard lint

Signed-off-by: Daneyon Hansen <daneyon.hansen@solo.io>

---------

Signed-off-by: Daneyon Hansen <daneyon.hansen@solo.io>
2026-09-02 01:01:13 +02:00
56 changed files with 3119 additions and 639 deletions

No files matched your search

+1
View File
@@ -1,6 +1,7 @@
# Ignore zitadel environment
.env
Caddyfile
agentgateway-stub.Caddyfile
management.json
turnserver.conf
zitadel.env
+1 -1
View File
@@ -19,7 +19,7 @@ check_docker_compose() {
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
$DOCKER_COMPOSE_COMMAND down --volumes
rm -f docker-compose.yml Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env
rm -f docker-compose.yml Caddyfile agentgateway-stub.Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env
rm -rf proxy-certs proxy-certs-no-ports
rm -f ../../.test-config.json ../playwright.env.json
rm -f ../fixtures/auth/owner.json ../fixtures/auth/user.json
+35 -1
View File
@@ -532,7 +532,7 @@ initEnvironment() {
echo "Generated files already exist, if you want to reinitialize the environment, please remove them first."
echo "You can use the following commands:"
echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes"
echo " rm -f docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports"
echo " rm -f docker-compose.yml Caddyfile agentgateway-stub.Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports"
echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard."
exit 1
fi
@@ -540,6 +540,7 @@ initEnvironment() {
echo Rendering initial files...
renderDockerCompose > docker-compose.yml
renderCaddyfile > Caddyfile
renderAgentGatewayStub > agentgateway-stub.Caddyfile
renderZitadelEnv > zitadel.env
echo "" > turnserver.conf
echo "" > management.json
@@ -612,6 +613,30 @@ initEnvironment() {
echo "Password: $ZITADEL_ADMIN_PASSWORD" | tee -a .env
}
# Saving an Agent Network provider makes management call the upstream and
# refuses the save unless it answers a model listing, so a provider spec needs
# a reachable OpenAI-compatible endpoint. agentgateway is self-hosted and has
# no public one; this stub stands in for it, inside the compose network where
# management can reach it and without depending on a vendor being up.
renderAgentGatewayStub() {
cat <<'EOF'
{
admin off
auto_https off
}
:8088 {
handle /v1/models {
header Content-Type application/json
respond `{"object":"list","data":[{"id":"agentgateway-e2e-model","object":"model","owned_by":"netbird-e2e"}]}` 200
}
handle {
respond 404
}
}
EOF
}
renderCaddyfile() {
cat <<EOF
{
@@ -818,6 +843,15 @@ services:
volumes:
- netbird_caddy_data:/data
- ./Caddyfile:/etc/caddy/Caddyfile
# Stand-in upstream for the Agent Network provider specs: answers the model
# listing management probes before it accepts a provider save. Reachable
# only from inside the compose network, at http://agentgateway-stub:8088.
agentgateway-stub:
image: caddy
restart: unless-stopped
networks: [ netbird ]
volumes:
- ./agentgateway-stub.Caddyfile:/etc/caddy/Caddyfile
# Management
management:
image: ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG}
+25
View File
@@ -24,6 +24,31 @@ export async function openControlCenter(page: Page, tab?: FlowView) {
await dismissBlockingOverlays(page);
}
/**
* Opens the groups view already showing `groupName`.
*
* The view picks its own group on first render (getFirstGroup: the highest
* peers_count that is a policy source), and that ranking runs over every group
* in the account — including ones a spec on the other worker seeded moments
* ago. Deleting the spec's own fixtures first is not enough to pin it, so
* select the group by name and let the view rebuild around it.
*/
export async function openGroupView(page: Page, groupName: string) {
await openControlCenter(page, "groups");
const selector = canvasNode(page, "select-group-node");
await expect(selector).toBeVisible();
// Unforced on purpose: the opening fitView animates the canvas, and the
// actionability check is what waits for the node to stop moving.
await selector.click();
await page.getByTestId("select-dropdown-search").fill(groupName);
await page
.locator('[role="option"]')
.filter({ hasText: groupName })
.first()
.click({ force: true });
await expect(selector).toContainText(groupName);
}
export async function switchFlowView(page: Page, view: FlowView) {
await dismissBlockingOverlays(page);
await page.getByTestId(`cc-flow-${view}`).click({ force: true });
@@ -0,0 +1,156 @@
/**
* Agent Network agentgateway provider spec.
*
* Exercises the catalog-driven provider flow against a management build that
* includes the agentgateway catalog entry. Older builds skip the test.
*/
import {
deleteAgentNetworkProvidersByPrefix,
listAgentNetworkCatalog,
supportsAgentNetworkSettingsBootstrap,
} from "../helpers/api";
import { navigateTo } from "../helpers/auth";
import { expect, test } from "../helpers/fixtures";
import { generateRandomName } from "../helpers/utils";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
const AGENTGATEWAY_CATALOG_ID = "agentgateway";
const PROVIDER_PREFIX = "e2e-agentgateway-";
test.describe
.serial("Agent Network agentgateway provider @agent-network", () => {
test("connect agentgateway with its trusted identity mapping", async ({
dashboardAsOwner: page,
}) => {
await page.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch {}
},
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
);
const catalog = await listAgentNetworkCatalog(page);
test.skip(
!catalog.some((entry) => entry.id === AGENTGATEWAY_CATALOG_ID),
`management catalog has no ${AGENTGATEWAY_CATALOG_ID} entry`,
);
test.skip(
!(await supportsAgentNetworkSettingsBootstrap(page)),
"management build does not support Agent Network settings bootstrap",
);
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
try {
await navigateTo(page, "/agent-network/providers");
await page
.getByTestId("connect-agent-network-provider")
.first()
.click({ force: true });
await page
.getByTestId("agent-network-provider-type")
.click({ force: true });
await page
.getByTestId("select-dropdown-search")
.fill(AGENTGATEWAY_CATALOG_ID);
await page
.getByTestId("agent-network-provider-option-agentgateway")
.click({ force: true });
// Saving a provider makes management call the upstream, and it refuses
// the save with a 422 unless the upstream answers a model listing — so a
// made-up host cannot be used here. agentgateway is self-hosted and has
// no public endpoint to point at, so create-test-env.sh runs a stub that
// answers that probe inside the compose network.
const upstreamURL = "http://agentgateway-stub:8088";
await page
.getByTestId("agent-network-provider-upstream-url")
.fill(upstreamURL);
await page
.getByTestId("agent-network-provider-api-key")
.fill("e2e-agentgateway-virtual-key");
const providerName = generateRandomName(PROVIDER_PREFIX);
await page.getByTestId("agent-network-provider-name").fill(providerName);
await page
.getByTestId("agent-network-provider-models-tab")
.click({ force: true });
await expect(
page.getByTestId("agent-network-provider-models-help"),
).toContainText("Empty = all catalog models");
await page
.getByTestId("agent-network-provider-continue")
.click({ force: true });
await expect(
page.getByTestId("agent-network-provider-mappings-tab"),
).toHaveAttribute("data-state", "active");
await expect(
page.getByTestId("agent-network-provider-user-mapping"),
).toContainText("x-netbird-user-id");
await expect(
page.getByTestId("agent-network-provider-groups-mapping"),
).toContainText("x-netbird-groups");
await expect(
page.getByTestId("agent-network-provider-groups-guidance"),
).toContainText("must not be used as an agentgateway authorization claim");
const createResponse = page.waitForResponse(
(response) =>
response.url().includes("/agent-network/providers") &&
response.request().method() === "POST",
{ timeout: 30_000 },
);
await page.getByTestId("agent-network-provider-submit").click({
force: true,
});
const created = await createResponse;
expect([200, 201]).toContain(created.status());
const body = created.request().postDataJSON();
expect(body).toMatchObject({
provider_id: AGENTGATEWAY_CATALOG_ID,
name: providerName,
upstream_url: upstreamURL,
models: [],
metadata_disabled: false,
});
expect(body).not.toHaveProperty("identity_header_user_id");
expect(body).not.toHaveProperty("identity_header_groups");
await expect(
page.getByTestId("agent-network-provider-modal"),
).toBeHidden();
await expect(page.getByTestId(providerName)).toBeVisible();
await expect(
page.getByTestId(`provider-models-${providerName}`),
).toHaveText("All Models");
await page.getByTestId(providerName).click({ force: true });
await expect(
page.getByTestId("agent-network-provider-modal"),
).toBeVisible();
await expect(
page.getByTestId("agent-network-provider-upstream-url"),
).toHaveValue(upstreamURL);
await page
.getByTestId("agent-network-provider-mappings-tab")
.click({ force: true });
await expect(
page.getByTestId("agent-network-provider-user-mapping"),
).toContainText("x-netbird-user-id");
await expect(
page.getByTestId("agent-network-provider-groups-mapping"),
).toContainText("x-netbird-groups");
await page
.getByTestId("agent-network-provider-modal")
.getByTestId("modal-close")
.click({ force: true });
} finally {
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
}
});
});
@@ -1,196 +0,0 @@
/**
* Agent Network bootstrap-cluster spec.
*
* The account's agent network endpoint is assigned once, from the proxy
* cluster the connect-provider wizard pins it to, and it is immutable after
* that. Not every live cluster can host it: the endpoint is a private service,
* reachable only from connected peers and authorised by their tunnel identity,
* which only a cluster with a connected embedded proxy (`netbird proxy`) 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 EMBEDDED_CLUSTER = "embedded.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.${EMBEDDED_CLUSTER}`,
proxy_address: EMBEDDED_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(EMBEDDED_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(EMBEDDED_CLUSTER);
} finally {
await close();
}
});
test("blocks setup when no listed cluster has an embedded proxy", 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("embedded proxy");
// 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();
}
});
});
@@ -0,0 +1,195 @@
/**
* Agent Network provider save: what the operator sees when the backend refuses.
*
* Saving a provider checks its upstream URL and credential against the vendor
* before storing anything, so a 422 naming the field to correct is an ordinary
* outcome of the form rather than a server fault. Four things have to hold,
* and each one has been wrong at some point:
*
* - while the check runs the submit says so and refuses a second click. The
* vendor call is the slow part and an unreachable upstream drags it out to
* a timeout, which looked exactly like a button that had not registered
* the first click.
* - exactly ONE toast, the shared "Request failed with status code N", which
* quotes the API's own sentence. A second toast from the save path said the
* same thing under a vaguer title.
* - the toast is styled as a failure. notify() renders green with a check
* mark unless told otherwise, so a refusal announced itself as a success.
* - the modal stays open with the typed values intact. The API never returns
* an API key, so closing the form loses it with nowhere to retype it.
*
* The refusal is mocked rather than provoked: the backend check ships with a
* management build these tests do not pin, and what is under test here is the
* dashboard's handling of the response, not the vendor call that produces it.
*
* Like the other agent-network specs, this one builds its own context rather
* than taking the shared dashboardAsOwner fixture: the Agent Network menu is
* deployment-gated behind a localStorage override that has to be in place
* before the first navigation, which addInitScript on an own context is the
* way to do. The route interception below is a second reason — the shared page
* is worker-scoped, and a 422 left on it would follow every later test.
*/
import { type Browser, expect, type Page, test } from "@playwright/test";
import { loginToApp, navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
const PROVIDERS_ENDPOINT = /\/api\/agent-network\/providers(\?|$)/;
const PROVIDER_PREFIX = "e2e-refused-";
const TITLE_TESTID = "notification-title";
// The message a refused save carries: the backend names which of the two
// fields is at fault, without a status code and without echoing the URL.
const REFUSAL = "the upstream url could not be reached: no such host";
// Matched case-insensitively on purpose. The backend lowercases its messages
// (WriteError does, and the copy is written for it) while the toast uppercases
// the first character before rendering. Asserting either spelling would pin
// the test to that transform rather than to the sentence the operator reads.
const REFUSAL_TEXT = new RegExp(
REFUSAL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
"i",
);
async function newAgentNetworkPage(browser: Browser): Promise<{
page: Page;
close: () => Promise<void>;
}> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
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 loginToApp(page, "owner");
return { page, close: () => context.close() };
}
// How long the mocked create takes to answer. The real check is a vendor round
// trip, so a save is never instant; holding the response gives the in-flight
// assertions a window to run in without racing the toast.
const REFUSAL_DELAY_MS = 1500;
// refuseProviderCreate answers the create with the 422 the credential check
// produces. Only POST is intercepted: the page still lists providers, and the
// settings bootstrap that may precede the create is left alone.
async function refuseProviderCreate(page: Page) {
await page.route(PROVIDERS_ENDPOINT, async (route) => {
if (route.request().method() !== "POST") return route.continue();
const origin = route.request().headers()["origin"] || "*";
await new Promise((resolve) => setTimeout(resolve, REFUSAL_DELAY_MS));
await route.fulfill({
status: 422,
headers: {
"content-type": "application/json",
"access-control-allow-origin": origin,
},
body: JSON.stringify({ code: 422, message: REFUSAL }),
});
});
}
test.describe
.serial("Agent Network refused provider save @agent-network", () => {
test("reports the refusal once and keeps the form open", async ({
browser,
}) => {
const { page, close } = await newAgentNetworkPage(browser);
try {
await refuseProviderCreate(page);
// navigateTo rather than goto: it dismisses the setup modal that greets a
// fresh account and clears the scroll lock it leaves behind, either of
// which swallows the clicks below.
await navigateTo(page, "/agent-network/providers");
await page
.getByRole("button", { name: "Connect Provider" })
.first()
.click({ force: true });
const providerName = generateRandomName(PROVIDER_PREFIX);
await page
.getByTestId("agent-network-provider-name")
.fill(providerName);
await page
.getByTestId("agent-network-provider-api-key")
.fill("sk-e2e-refused-key");
// The submit lives on the Models tab — the Provider tab's primary button
// only advances to it.
await page.getByRole("tab", { name: "Models" }).click({ force: true });
const submit = page.getByTestId("agent-network-provider-submit");
await expect(submit).toBeEnabled();
const refusal = page.waitForResponse(
(resp) =>
PROVIDERS_ENDPOINT.test(resp.url()) &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await submit.click({ force: true });
// ---- the wait is visible while it lasts ----
// A second create is not idempotent, so the button has to say it is
// working and stop taking clicks rather than sit there looking untouched.
// Asserted before the response is awaited: the mock holds it open for
// exactly this window.
await expect(submit).toContainText("Connecting provider");
await expect(submit).toBeDisabled();
expect((await refusal).status()).toBe(422);
// ---- the toast says what the API said ----
const title = page.getByTestId(TITLE_TESTID).first();
await expect(title).toContainText("Request failed with status code 422");
await expect(
page.locator("[data-toast-notification]").first(),
).toContainText(REFUSAL_TEXT);
// ---- and only that toast ----
// The save path used to add its own on top, so the count is the
// assertion rather than the presence of the right one.
await expect(page.locator("[data-toast-notification]")).toHaveCount(1);
await expect(page.getByText("Failed to connect provider")).toHaveCount(0);
// ---- styled as a failure, not a success ----
// notify() paints the icon tile green with a check unless the caller
// says otherwise, which is how a refusal once looked like a success.
//
// A failure reaches the tile two ways — notify()'s own error state, and
// a caller passing its own colour and icon, which is what the shared
// request-failed toast does — so what has to hold is the negative: this
// is not the default success tile.
const icon = page.getByTestId("notification-icon");
await expect(icon).toBeVisible();
await expect(icon).not.toHaveAttribute("data-variant", "success");
// ---- the form is still there, still holding what was typed ----
// The submit only exists while the modal is open, so its presence is the
// check that nothing closed underneath the toast. It also has to come
// back out of the in-flight state, or the retry the toast asks for is
// impossible.
await expect(submit).toBeVisible();
await expect(submit).toBeEnabled();
await expect(submit).toContainText("Connect Provider");
await page.getByRole("tab", { name: "Provider" }).click({ force: true });
await expect(
page.getByTestId("agent-network-provider-name"),
).toHaveValue(providerName);
// The key matters most: the API never returns one, so a form that lost
// it leaves the operator with nothing to correct.
await expect(
page.getByTestId("agent-network-provider-api-key"),
).toHaveValue("sk-e2e-refused-key");
} finally {
await close();
}
});
});
@@ -364,13 +364,17 @@ test.describe.serial("Control Center Draft Matrix @control-center", () => {
}) => {
const group = await place(page, "group", 0.6, 0.5);
await expectChangeCount(page, 1);
// React Flow ignores delete keys while an input is focused, and the panel's
// search takes focus on click, so blur it before pressing Backspace.
await group.click();
await expect(group).toHaveClass(/selected/);
await page.evaluate(
() => (document.activeElement as HTMLElement | null)?.blur(),
);
// React Flow ignores delete keys while an input is focused, and the panel
// the click opens takes focus into its search — from an effect, about
// 100ms later. Blurring before that lands leaves the input to claim focus
// afterwards and swallow the Backspace, so wait for the focus, then drop
// it.
const panelSearch = page.locator("#cc-group-panel input").first();
await expect(panelSearch).toBeFocused();
await panelSearch.blur();
await expect(panelSearch).not.toBeFocused();
await page.keyboard.press("Backspace");
await expect(canvasNode(page, "group-new-")).toHaveCount(0);
await expectChangeCount(page, 0);
@@ -19,6 +19,7 @@ import {
dismissBlockingOverlays,
enterDraft,
openControlCenter,
openGroupView,
resetDraftState,
reviewButton,
} from "../helpers/control-center";
@@ -37,7 +38,6 @@ test.describe
await deleteGroupsByPrefix(page, PREFIX);
});
// Clean-slated so the draft group view's auto-select is deterministic.
async function seedPolicyGroupView(page: Page) {
await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX);
@@ -49,7 +49,9 @@ test.describe
src.id,
dst.id,
);
await openControlCenter(page, "groups");
// Selected by name: the view's own pick ranks every group in the account,
// so a peer-bearing group from another spec on the other worker wins it.
await openGroupView(page, src.name);
await enterDraft(page);
const policyNode = canvasNode(page, `policy-${policy.id}`);
await expect(policyNode).toBeVisible({ timeout: 15_000 });
+7 -14
View File
@@ -18,6 +18,7 @@ import {
clickContextMenuItem,
dismissBlockingOverlays,
openControlCenter,
openGroupView,
resetDraftState,
switchFlowView,
} from "../helpers/control-center";
@@ -47,8 +48,6 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
});
async function seedPolicyAndOpenGroupView(page: Page, enabled = true) {
// The group view auto-selects a group that HAS a policy, so stale policies
// would make the rendered group non-deterministic.
await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX);
@@ -64,15 +63,10 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
);
const policyNode = canvasNode(page, `policy-${policy.id}`);
// A stale SWR read or a build/fit race can fail to place the node.
for (let attempt = 0; attempt < 3; attempt++) {
await openControlCenter(page, "groups");
const shown = await policyNode
.waitFor({ state: "visible", timeout: 10_000 })
.then(() => true)
.catch(() => false);
if (shown) break;
}
// Selecting src by name rather than trusting the view's own pick: the
// auto-select ranks every group in the account, so a peer-bearing group
// another spec seeded on the other worker outranks this one.
await openGroupView(page, src.name);
await expect(policyNode).toBeVisible({ timeout: 10_000 });
return { src, dst, policy, policyNode };
}
@@ -512,7 +506,6 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
test("Should save group resource membership in live mode (correct payload)", async ({
dashboardAsOwner: page,
}) => {
// Clean slate so the group view deterministically lands on our group.
await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX);
@@ -529,7 +522,7 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
[],
);
await openControlCenter(page, "groups");
await openGroupView(page, src.name);
const groupNode = canvasNode(page, `group-${dst.id}`);
await expect(groupNode).toBeVisible({ timeout: 15_000 });
@@ -610,7 +603,7 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
);
await createPolicy(page, generateRandomName(PREFIX), src.id, d2.id);
await openControlCenter(page, "groups");
await openGroupView(page, src.name);
const policyNode = canvasNode(page, `policy-${p1.id}`);
await expect(policyNode).toBeVisible({ timeout: 15_000 });
await expect(
+7
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@axa-fr/react-oidc": "^7.26.3",
"@dagrejs/dagre": "^1.1.5",
"@novnc/novnc": "^1.7.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
@@ -1709,6 +1710,12 @@
"node": ">=12.4.0"
}
},
"node_modules/@novnc/novnc": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
"integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
"license": "MPL-2.0"
},
"node_modules/@oxc-project/types": {
"version": "0.144.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
+1
View File
@@ -25,6 +25,7 @@
"dependencies": {
"@axa-fr/react-oidc": "^7.26.3",
"@dagrejs/dagre": "^1.1.5",
"@novnc/novnc": "^1.7.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
+14 -2
View File
@@ -70,8 +70,10 @@ import ReverseProxiesProvider, {
import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent";
import { PeerEditIPModal } from "@/modules/peer/PeerEditIPModal";
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
import { isSSHSupportedOnOS } from "@/modules/remote-access/osSupport";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import { VNCButton } from "@/modules/remote-access/vnc/VNCButton";
import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings";
export default function PeerPage() {
@@ -430,6 +432,13 @@ const PeerOverviewTabContent = () => {
const { peer } = usePeer();
const { permission } = usePermissions();
const { selectedGroups, setSelectedGroups } = usePeerSettings();
const isSSHSupported = isSSHSupportedOnOS(peer?.os);
// Named in the order the buttons appear below, and only the ones that do.
// RDP and VNC are unconditional: those servers need not be ours, so any
// system may be running one.
const remoteAccessMethods = [isSSHSupported && "SSH", "RDP", "VNC"]
.filter(Boolean)
.join(", ");
return (
<div className={"px-8"}>
@@ -458,14 +467,17 @@ const PeerOverviewTabContent = () => {
</div>
)}
<PeerSSHToggle />
{isSSHSupported && <PeerSSHToggle />}
<div>
<Label>Remote Access</Label>
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
<HelpText>
Connect directly to this peer via {remoteAccessMethods}.
</HelpText>
<div className="flex gap-3">
<SSHButton peer={peer} />
<RDPButton peer={peer} />
<VNCButton peer={peer} />
</div>
</div>
</div>
+12 -17
View File
@@ -1,8 +1,7 @@
"use client";
import { notify } from "@components/Notification";
import { sendErrorNotification } from "@/modules/remote-access/errorNotification";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { IconCircleX } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import { Loader2Icon } from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
@@ -24,7 +23,7 @@ import {
import { cn } from "@utils/helpers";
export default function RDPPage() {
const { peerId } = useRDPQueryParams();
const { peerId, ipVersion } = useRDPQueryParams();
const {
data: peer,
@@ -35,7 +34,7 @@ export default function RDPPage() {
return (
<div className={"w-screen h-screen overflow-hidden fixed inset-0"}>
{peerId && peer && !isLoading ? (
<RDPSession key={peer.id} peer={peer} />
<RDPSession key={peer.id} peer={peer} ipVersion={ipVersion} />
) : (
<FullScreenLoading />
)}
@@ -45,9 +44,10 @@ export default function RDPPage() {
type Props = {
peer: Peer;
ipVersion: string | null;
};
function RDPSession({ peer }: Props) {
function RDPSession({ peer, ipVersion }: Props) {
const client = useNetBirdClient();
const [isNetBirdConnecting, setIsNetBirdConnecting] = useState(false);
const rdp = useRemoteDesktop(client);
@@ -55,20 +55,14 @@ function RDPSession({ peer }: Props) {
const [credentials, setCredentials] = useState<RDPCredentials | null>(null);
const connected = useRef(false);
// Dial the IPv6 address when IPv6 was selected, IPv4 otherwise.
const rdpHost =
credentials?.ipVersion === "6" && peer.ipv6 ? peer.ipv6 : peer.ip;
useEffect(() => {
document.title = `${peer.name} - ${peer.ip} - RDP`;
}, [peer.ip, peer.name, connected, rdp]);
const sendErrorNotification = (title: string, message: string) => {
notify({
title: title,
description: message,
icon: <IconCircleX size={24} />,
backgroundColor: "bg-red-500",
duration: 10000,
});
};
/**
* Reset the RDP session state but keep the NetBird client connected,
* so a retry does not pay the full engine reconnect.
@@ -133,7 +127,7 @@ function RDPSession({ peer }: Props) {
if (!credentials) return;
try {
const result = await rdp.connect({
hostname: peer.ip,
hostname: rdpHost,
port: credentials.port,
username: credentials.username,
password: credentials.password,
@@ -152,7 +146,7 @@ function RDPSession({ peer }: Props) {
setCredentialsModal(true);
await reset();
}
}, [credentials, peer.ip, rdp, reset]);
}, [credentials, rdpHost, rdp, reset]);
/**
* Establish RDP session when NetBird connection is ready
@@ -222,6 +216,7 @@ function RDPSession({ peer }: Props) {
peer={peer}
onConnect={connect}
loading={isLoading}
initialIpVersion={ipVersion}
/>
{/* Certificate Modal */}
+15 -9
View File
@@ -13,7 +13,7 @@ import {
useNetBirdClient,
} from "@/modules/remote-access/useNetBirdClient";
import {
isNativeSSHSupported,
usesStandardSSHPort,
isNetbirdSSHProtocolSupported,
} from "@utils/version";
@@ -83,8 +83,14 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
const isClientDisconnected = client.status === NetBirdStatus.DISCONNECTED;
const isClientConnecting = client.status === NetBirdStatus.CONNECTING;
// Use the FQDN when an IP version is specified so the dialer resolves to the correct address family.
const sshHost = ipVersion ? peer.dns_label || peer.ip : peer.ip;
// Dial the IPv6 address when ip_version=6 is requested and the peer has one,
// IPv4 otherwise. peer.ip is always IPv4, so a request for "6" on a peer
// without an IPv6 address has to fall back on the version too, or the dialer
// is told to use a family the host doesn't belong to.
const requestedIPv6 = ipVersion === "6" ? peer.ipv6 : undefined;
const sshHost = requestedIPv6 ?? peer.ip;
const sshIpVersion =
ipVersion === "6" ? (requestedIPv6 ? "6" : "4") : ipVersion;
useEffect(() => {
document.title = `${username}@${sshHost} - ${peer.hostname}`;
@@ -95,7 +101,7 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
if (isSSHConnected || isSSHConnecting) return;
connected.current = false;
try {
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
const aclPort = usesStandardSSHPort(peer.version) ? "22022" : port;
const protocol = isNetbirdSSHProtocolSupported(peer.version)
? "netbird-ssh"
: "tcp";
@@ -105,7 +111,7 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
hostname: sshHost,
port: Number(port),
username,
ipVersion: ipVersion || undefined,
ipVersion: sshIpVersion || undefined,
});
} catch (error) {
console.error("Reconnection failed:", error);
@@ -122,7 +128,7 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
connected.current = true;
try {
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
const aclPort = usesStandardSSHPort(peer.version) ? "22022" : port;
const protocol = isNetbirdSSHProtocolSupported(peer.version)
? "netbird-ssh"
: "tcp";
@@ -132,7 +138,7 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
hostname: sshHost,
port: Number(port),
username,
ipVersion: ipVersion || undefined,
ipVersion: sshIpVersion || undefined,
});
if (res === SSHStatus.CONNECTED) {
sshConnectedOnce.current = true;
@@ -168,7 +174,7 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
return (
<DisconnectedMessage
username={username}
peerIp={peer.ip}
peerIp={sshHost}
onReconnect={handleReconnect}
/>
);
@@ -178,7 +184,7 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
<>
{session && <Terminal session={session} onClose={disconnect} />}
{!isSSHConnected && (
<LoadingMessage message={`Connecting to ${username}@${peer.ip}...`} />
<LoadingMessage message={`Connecting to ${username}@${sshHost}...`} />
)}
</>
);
File diff suppressed because it is too large. Load diff
+25
View File
@@ -81,6 +81,31 @@ p {
}
/**
* VNC viewport: noVNC writes the cursor inline on the canvas it creates
* inside our container, not on the container itself. When there's no
* remote sprite yet the inline value is `cursor: none`, and once a Cursor
* pseudo-encoded sprite arrives it becomes `cursor: url(...)`. The :not()
* rule below forces the browser default arrow as long as the inline
* style isn't using a url(), so the user has a visible pointer in the
* gap before any sprite is delivered and the remote sprite still wins
* once it shows up. Skip this for vnc-cursor-remote so the second rule
* below can actually hide the pointer when compositing is on.
*/
.vnc-cursor:not(.vnc-cursor-remote) canvas:not([style*="url("]) {
cursor: default !important;
}
/**
* In "show remote cursor" mode the server composites its cursor sprite
* directly into the captured framebuffer, so the local pointer must be
* hidden to avoid two stacked cursors. Matches both the empty/none and
* url() inline cases.
*/
.vnc-cursor-remote canvas {
cursor: none !important;
}
.timescape {
@apply flex items-center gap-[1px] rounded-md py-2 px-3 select-none w-fit cursor-text bg-nb-gray-900;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

+24 -8
View File
@@ -4,6 +4,7 @@ import * as React from "react";
import { useEffect } from "react";
const QUERY_PARAMS_KEY = "netbird-query-params";
const PRESERVE_QUERY_PARAMS_PATHS = ["/peer/ssh", "/peer/rdp", "/peer/vnc"];
type StoredQueryParams = { path: string; params: string };
@@ -21,6 +22,12 @@ const VALID_PARAMS = [
"page_size",
"user",
"port",
"ip_version",
"mode",
"quality",
"scale",
"resize",
"cursor",
];
function filterAllowedParams(raw: string): string {
@@ -32,6 +39,13 @@ function filterAllowedParams(raw: string): string {
return output.toString();
}
function storeQueryParams(path: string, rawParams: string) {
const filtered = filterAllowedParams(rawParams);
if (!filtered) return;
const data: StoredQueryParams = { path, params: filtered };
localStorage.setItem(QUERY_PARAMS_KEY, JSON.stringify(data));
}
type Props = {
children: React.ReactNode;
};
@@ -42,6 +56,15 @@ export const SecureProvider = ({ children }: Props) => {
useEffect(() => {
if (isAuthenticated) {
try {
const params = window.location.search.substring(1);
// Persist on every render while on a remote-access path. The OIDC
// library can fire a redirect synchronously when an access token
// expires, before the !isAuthenticated branch of this effect ever
// runs, so the peer id would be lost across the round-trip otherwise.
if (PRESERVE_QUERY_PARAMS_PATHS.includes(currentPath) && params) {
storeQueryParams(currentPath, params);
return;
}
const stored = localStorage.getItem(QUERY_PARAMS_KEY);
if (stored && !window.location.search) {
const data: StoredQueryParams = JSON.parse(stored);
@@ -59,14 +82,7 @@ export const SecureProvider = ({ children }: Props) => {
try {
const params = window.location.search.substring(1);
if (params) {
const filtered = filterAllowedParams(params);
if (filtered) {
const data: StoredQueryParams = {
path: currentPath,
params: filtered,
};
localStorage.setItem(QUERY_PARAMS_KEY, JSON.stringify(data));
}
storeQueryParams(currentPath, params);
}
} catch (e) {}
}
+1 -8
View File
@@ -9,9 +9,6 @@ type Props = {
icon?: React.ReactNode;
children?: React.ReactNode;
className?: string;
/** Lets callers mark a callout so tests can assert on the state it
* announces rather than on its copy. */
"data-testid"?: string;
} & CalloutVariants;
export const calloutVariants = cva(
@@ -34,13 +31,9 @@ export const Callout = ({
icon = <InfoIcon size={14} className={"shrink-0 relative top-[3px]"} />,
className,
variant = "default",
"data-testid": dataTestId,
}: Props) => {
return (
<div
className={cn(calloutVariants({ variant }), className)}
data-testid={dataTestId}
>
<div className={cn(calloutVariants({ variant }), className)}>
{icon}
<div>{children}</div>
</div>
Loaded 20 of 56 files, more files were not shown because too many files have changed in this diff. Show more