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
47 changed files with 2378 additions and 252 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 });
@@ -59,7 +59,12 @@ test.describe
.getByTestId("agent-network-provider-option-agentgateway")
.click({ force: true });
const upstreamURL = "https://agentgateway.e2e.example";
// 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);
@@ -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) {}
}
+6 -2
View File
@@ -3,16 +3,20 @@ import LoadingIcon from "@/assets/icons/LoadingIcon";
type Props = {
fullScreen?: boolean;
// label, when set, renders under the spinner so callers can spell out
// which step of a multi-stage flow is in progress.
label?: string;
};
export default function FullScreenLoading({ fullScreen = true }: Props) {
export default function FullScreenLoading({ fullScreen = true, label }: Props) {
return (
<div
className={cn(
"flex items-center justify-center w-screen",
"flex flex-col items-center justify-center gap-4 w-screen",
fullScreen && "h-screen",
)}
>
<LoadingIcon className="fill-netbird" size={44} />
{label && <div className="text-sm text-nb-gray-400">{label}</div>}
</div>
);
}
+7
View File
@@ -17,7 +17,14 @@ export interface Workload {
// Parameters for bundle job
export interface BundleJobParameters {
anonymize: boolean;
// Anonymization level applied when anonymize is enabled. "default" keeps
// internal IP ranges readable; "strict" also anonymizes them. Omitted or
// empty resolves to the default on the peer.
anonymize_level?: "default" | "strict";
bundle_for: boolean;
bundle_for_time: number;
log_file_count: number;
// Upload service URL the peer requests an upload URL from. Empty selects the
// default upload server.
upload_url?: string;
}
+1
View File
@@ -46,4 +46,5 @@ export interface PeerLocalFlags {
rosenpass_enabled: boolean;
rosenpass_permissive: boolean;
server_ssh_allowed: boolean;
server_vnc_allowed: boolean;
}
Loaded 20 of 47 files, more files were not shown because too many files have changed in this diff. Show more