mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f34b3d8331 | ||
|
|
107e326750 | ||
|
|
3303c5de99 | ||
|
|
a0d497239b | ||
|
|
ce006c5f28 | ||
|
|
9e9b88d36c | ||
|
|
f1a1ec27cf | ||
|
|
97bb3742ea | ||
|
|
881a9d7a83 | ||
|
|
a867170355 | ||
|
|
fd13579e50 | ||
|
|
e6d5faf314 | ||
|
|
b13bce56d8 | ||
|
|
e92ce98700 | ||
|
|
63cb86957a | ||
|
|
ddf5d30045 | ||
|
|
5acf358ab8 | ||
|
|
66f4eb4dd6 | ||
|
|
c907fd9475 | ||
|
|
a6780ea7ee | ||
|
|
9a3fc271da |
No files matched your search
@@ -35,7 +35,7 @@ export default defineConfig({
|
||||
},
|
||||
testDir: "./tests",
|
||||
webServer: {
|
||||
command: "npx serve out -p 1337 --no-request-logging",
|
||||
command: "npx serve@latest out -p 1337 --no-request-logging",
|
||||
port: 1337,
|
||||
reuseExistingServer: true,
|
||||
cwd: path.resolve(__dirname, ".."),
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* item, which toggles purely on the focused-view flag and does not depend on
|
||||
* premium permission modules.
|
||||
*/
|
||||
import { type Browser, expect, type Page, test } from "@playwright/test";
|
||||
import { test, expect, type Browser, type Page } from "@playwright/test";
|
||||
import { loginToApp, navigateTo } from "../helpers/auth";
|
||||
|
||||
const SIGNUP_SOURCE_KEY = "netbird-signup-source";
|
||||
@@ -149,7 +149,6 @@ const FOCUS_HIDDEN_NAV = ["Networks", "Reverse Proxy", "DNS", "Activity"];
|
||||
// Agent Network view routes that make up the focused surface.
|
||||
const AGENT_VIEW_PATHS = [
|
||||
"/agent-network/providers",
|
||||
"/agent-network/playground",
|
||||
"/agent-network/policies",
|
||||
"/agent-network/usage",
|
||||
"/agent-network/configuration",
|
||||
|
||||
@@ -1,426 +0,0 @@
|
||||
import {
|
||||
type Browser,
|
||||
expect,
|
||||
type Page,
|
||||
type Route,
|
||||
test,
|
||||
} from "@playwright/test";
|
||||
import { loginToApp } from "../helpers/auth";
|
||||
|
||||
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
|
||||
const ALLOW = { create: true, read: true, update: true, delete: true };
|
||||
const DENY = { create: false, read: false, update: false, delete: false };
|
||||
const SETTINGS = {
|
||||
endpoint: "violet.proxy.example.com",
|
||||
proxy_address: "proxy.example.com",
|
||||
dedicated: false,
|
||||
enable_log_collection: true,
|
||||
enable_prompt_collection: false,
|
||||
redact_pii: false,
|
||||
access_log_retention_days: 30,
|
||||
};
|
||||
|
||||
const ALICE = {
|
||||
id: "user-alice",
|
||||
email: "alice@example.com",
|
||||
name: "Alice",
|
||||
role: "user",
|
||||
status: "active",
|
||||
auto_groups: ["group-engineering"],
|
||||
permissions: { is_restricted: false, modules: {} },
|
||||
};
|
||||
const BOB = {
|
||||
...ALICE,
|
||||
id: "user-bob",
|
||||
email: "bob@example.com",
|
||||
name: "Bob",
|
||||
auto_groups: [],
|
||||
};
|
||||
|
||||
function currentUser(peerRead = true, groupRead = true) {
|
||||
return {
|
||||
...ALICE,
|
||||
id: "owner-1",
|
||||
email: "owner@example.com",
|
||||
name: "Owner",
|
||||
role: "owner",
|
||||
is_current: true,
|
||||
permissions: {
|
||||
is_restricted: false,
|
||||
modules: {
|
||||
services: ALLOW,
|
||||
"agent_network.providers": ALLOW,
|
||||
peers: peerRead ? ALLOW : DENY,
|
||||
users: peerRead ? ALLOW : DENY,
|
||||
groups: groupRead ? ALLOW : DENY,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function fulfillJSON(route: Route, body: unknown) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function mockPlaygroundDependencies(
|
||||
page: Page,
|
||||
opts: { peerRead?: boolean; groupRead?: boolean } = {},
|
||||
) {
|
||||
await page.route("**/api/users/current**", (route) =>
|
||||
fulfillJSON(route, currentUser(opts.peerRead, opts.groupRead)),
|
||||
);
|
||||
await page.route("**/api/users?**", (route) => {
|
||||
const serviceUser = new URL(route.request().url()).searchParams.get(
|
||||
"service_user",
|
||||
);
|
||||
return fulfillJSON(route, serviceUser === "true" ? [] : [ALICE, BOB]);
|
||||
});
|
||||
await page.route("**/api/peers**", (route) =>
|
||||
fulfillJSON(route, [
|
||||
{
|
||||
id: "peer-alice",
|
||||
name: "Alice Laptop",
|
||||
hostname: "alice-laptop",
|
||||
ip: "100.64.0.10",
|
||||
user_id: ALICE.id,
|
||||
},
|
||||
{
|
||||
id: "peer-bob",
|
||||
name: "Bob Laptop",
|
||||
hostname: "bob-laptop",
|
||||
ip: "100.64.0.11",
|
||||
user_id: BOB.id,
|
||||
},
|
||||
]),
|
||||
);
|
||||
await page.route("**/api/groups**", (route) =>
|
||||
fulfillJSON(route, [
|
||||
{ id: "group-engineering", name: "Engineering", peers: [] },
|
||||
{ id: "group-sales", name: "Sales", peers: [] },
|
||||
]),
|
||||
);
|
||||
await page.route("**/api/agent-network/settings**", (route) =>
|
||||
fulfillJSON(route, SETTINGS),
|
||||
);
|
||||
for (const path of ["providers", "policies", "guardrails", "budget-rules"]) {
|
||||
await page.route(`**/api/agent-network/${path}**`, (route) =>
|
||||
fulfillJSON(route, []),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function openPlayground(
|
||||
browser: Browser,
|
||||
opts: { peerRead?: boolean; groupRead?: boolean } = {},
|
||||
) {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
await context.addInitScript(
|
||||
([key, value]) =>
|
||||
window.localStorage.setItem(key as string, value as string),
|
||||
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
|
||||
);
|
||||
const page = await context.newPage();
|
||||
await loginToApp(page, "owner");
|
||||
await mockPlaygroundDependencies(page, opts);
|
||||
await page.goto("/agent-network/playground");
|
||||
return { page, close: () => context.close() };
|
||||
}
|
||||
|
||||
async function selectAlicePeer(page: Page) {
|
||||
await page.getByRole("button", { name: "Select a user..." }).click();
|
||||
await page.getByText("Alice", { exact: true }).click();
|
||||
const peerSelect = page.getByRole("combobox", { name: "Peer" });
|
||||
await peerSelect.click();
|
||||
const alicePeer = page.getByRole("option", { name: /Alice Laptop/ });
|
||||
await expect(alicePeer).toBeVisible();
|
||||
await expect(page.getByRole("option", { name: /Bob Laptop/ })).toHaveCount(0);
|
||||
await alicePeer.click();
|
||||
}
|
||||
|
||||
test.describe("Agent Network playground @agent-network", () => {
|
||||
test("runs peer and synthetic group requests with raw result states", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openPlayground(browser);
|
||||
const requests: unknown[] = [];
|
||||
let call = 0;
|
||||
await page.route("**/api/agent-network/playground", async (route) => {
|
||||
requests.push(route.request().postDataJSON());
|
||||
call += 1;
|
||||
if (call === 1) {
|
||||
return fulfillJSON(route, {
|
||||
status_code: 200,
|
||||
headers: [
|
||||
{ name: "Content-Type", values: ["text/event-stream"] },
|
||||
{ name: "X-Test", values: ["one", "two"] },
|
||||
],
|
||||
body: 'data: {"delta":"pong"}\n\n',
|
||||
body_encoding: "utf8",
|
||||
body_truncated: true,
|
||||
identity: {
|
||||
user_id: ALICE.id,
|
||||
user_email: ALICE.email,
|
||||
group_ids: ["group-engineering"],
|
||||
group_names: ["Engineering"],
|
||||
},
|
||||
policy: {
|
||||
decision: "allow",
|
||||
reason: "",
|
||||
provider_surface: "openai",
|
||||
model: "gpt-4o",
|
||||
resolved_provider_id: "provider-1",
|
||||
authorising_group_ids: ["group-engineering"],
|
||||
selected_policy_id: "policy-1",
|
||||
attribution_group_id: "group-engineering",
|
||||
},
|
||||
});
|
||||
}
|
||||
return fulfillJSON(route, {
|
||||
status_code: 403,
|
||||
headers: [{ name: "Content-Type", values: ["application/json"] }],
|
||||
body: "/wA=",
|
||||
body_encoding: "base64",
|
||||
body_truncated: false,
|
||||
identity: {
|
||||
user_id: "",
|
||||
user_email: "",
|
||||
group_ids: ["group-sales"],
|
||||
group_names: ["Sales"],
|
||||
},
|
||||
policy: {
|
||||
decision: "deny",
|
||||
reason: "no_authorised_provider",
|
||||
provider_surface: "openai",
|
||||
model: "gpt-4o",
|
||||
resolved_provider_id: "",
|
||||
authorising_group_ids: [],
|
||||
selected_policy_id: "",
|
||||
attribution_group_id: "",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 1, name: "Playground" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
"This sends a real provider request. It can incur cost and updates usage, budgets, and access logs for the emulated identity.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await selectAlicePeer(page);
|
||||
await page.getByRole("button", { name: "Run live request" }).click();
|
||||
const response = page.getByRole("region", {
|
||||
name: "Response inspector",
|
||||
});
|
||||
await expect(response.getByText("HTTP 200")).toBeVisible();
|
||||
await expect(response).toContainText('data: {"delta":"pong"}');
|
||||
await expect(response.getByText(/Response exceeded 8 MiB/)).toBeVisible();
|
||||
expect(requests[0]).toMatchObject({
|
||||
principal: { kind: "peer", id: "peer-alice" },
|
||||
});
|
||||
|
||||
await response.getByRole("tab", { name: /Headers/i }).click();
|
||||
await expect(
|
||||
response.getByText("Content-Type", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(response.getByText("one", { exact: true })).toBeVisible();
|
||||
await expect(response.getByText("two", { exact: true })).toBeVisible();
|
||||
|
||||
await response.getByRole("tab", { name: /Details/i }).click();
|
||||
await expect(
|
||||
response.getByText(ALICE.email, { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
response.getByText("Engineering (group-engineering)", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
response.getByText("provider-1", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
response.getByText("policy-1", { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("radio", { name: /Synthetic group/ }).click();
|
||||
await page.getByRole("button", { name: "Select a group..." }).click();
|
||||
await page.getByText("Sales", { exact: true }).click();
|
||||
await expect(
|
||||
page.getByText(
|
||||
"Synthetic group identity: per-user limits do not apply.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Run live request" }).click();
|
||||
await expect(response.getByText("HTTP 403")).toBeVisible();
|
||||
await response.getByRole("tab", { name: /Body/i }).click();
|
||||
const bodyPanel = response.getByRole("tabpanel", { name: /Body/i });
|
||||
await expect(bodyPanel.getByText("base64")).toBeVisible();
|
||||
await expect(bodyPanel.getByText("/wA=")).toBeVisible();
|
||||
expect(requests[1]).toMatchObject({
|
||||
principal: { kind: "group", id: "group-sales" },
|
||||
});
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("blocks protected headers and cancels an in-flight request", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openPlayground(browser);
|
||||
await page.route("**/api/agent-network/playground", async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
||||
return fulfillJSON(route, {});
|
||||
});
|
||||
try {
|
||||
await selectAlicePeer(page);
|
||||
const headerName = page.getByLabel("Header 1 name");
|
||||
await headerName.fill("Authorization");
|
||||
await expect(page.getByText(/managed by NetBird/)).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Run live request" }),
|
||||
).toBeDisabled();
|
||||
|
||||
await headerName.fill("Content-Type");
|
||||
await page.getByRole("button", { name: "Run live request" }).click();
|
||||
await expect(page.getByRole("button", { name: "Cancel" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(page.getByRole("button", { name: "Cancel" })).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("region", { name: "Response inspector" }),
|
||||
).not.toContainText("Request failed");
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows management failures with their request ID", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openPlayground(browser);
|
||||
await page.route("**/api/agent-network/playground", (route) =>
|
||||
route.fulfill({
|
||||
status: 503,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Request-Id": "request-503",
|
||||
"Access-Control-Expose-Headers": "X-Request-Id",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: 503,
|
||||
message: "No playground-capable proxy is connected.",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
await selectAlicePeer(page);
|
||||
await page.getByRole("button", { name: "Run live request" }).click();
|
||||
|
||||
const response = page.getByRole("region", {
|
||||
name: "Response inspector",
|
||||
});
|
||||
await expect(response.getByRole("alert")).toContainText(
|
||||
"No playground-capable proxy is connected.",
|
||||
);
|
||||
await expect(response.getByRole("alert")).toContainText(
|
||||
"Request ID: request-503",
|
||||
);
|
||||
await expect(response).not.toContainText("HTTP 503");
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the request and response workspace responsive", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openPlayground(browser);
|
||||
|
||||
try {
|
||||
const request = page.getByRole("region", { name: "Request builder" });
|
||||
const response = page.getByRole("region", {
|
||||
name: "Response inspector",
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await expect(request).toBeVisible();
|
||||
await expect(response).toBeVisible();
|
||||
const desktopRequest = await request.boundingBox();
|
||||
const desktopResponse = await response.boundingBox();
|
||||
expect(desktopRequest).not.toBeNull();
|
||||
expect(desktopResponse).not.toBeNull();
|
||||
expect(Math.abs(desktopRequest!.y - desktopResponse!.y)).toBeLessThan(4);
|
||||
expect(desktopResponse!.x).toBeGreaterThanOrEqual(
|
||||
desktopRequest!.x + desktopRequest!.width,
|
||||
);
|
||||
|
||||
await page.setViewportSize({ width: 1024, height: 900 });
|
||||
const narrowRequest = await request.boundingBox();
|
||||
const narrowResponse = await response.boundingBox();
|
||||
expect(narrowRequest).not.toBeNull();
|
||||
expect(narrowResponse).not.toBeNull();
|
||||
expect(narrowResponse!.y).toBeGreaterThanOrEqual(
|
||||
narrowRequest!.y + narrowRequest!.height,
|
||||
);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth ===
|
||||
document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 1, name: "Playground" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
request.getByRole("button", { name: "Run live request" }),
|
||||
).toBeVisible();
|
||||
await expect(request).toBeVisible();
|
||||
await expect(response).toBeVisible();
|
||||
const mobileRequest = await request.boundingBox();
|
||||
const mobileResponse = await response.boundingBox();
|
||||
expect(mobileRequest).not.toBeNull();
|
||||
expect(mobileResponse).not.toBeNull();
|
||||
expect(mobileResponse!.y).toBeGreaterThanOrEqual(
|
||||
mobileRequest!.y + mobileRequest!.height,
|
||||
);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth ===
|
||||
document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("disables only the principal mode without read permission", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openPlayground(browser, {
|
||||
peerRead: false,
|
||||
groupRead: true,
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
page.getByRole("radio", { name: /Peer-backed user/ }),
|
||||
).toBeDisabled();
|
||||
await expect(
|
||||
page.getByRole("radio", { name: /Synthetic group/ }),
|
||||
).toBeEnabled();
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Generated
+15
-827
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -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",
|
||||
@@ -112,7 +113,6 @@
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8",
|
||||
"prettier": "3.0.3",
|
||||
"serve": "^14.2.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
|
||||
import PeersProvider from "@/contexts/PeersProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import AIProvidersProvider from "@/modules/agent-network/AIProvidersProvider";
|
||||
import AgentNetworkPlayground from "@/modules/agent-network/playground/AgentNetworkPlayground";
|
||||
|
||||
function PlaygroundPageContent() {
|
||||
const { permission } = usePermissions();
|
||||
const canReadPrincipal = Boolean(
|
||||
(permission?.peers?.read && permission?.users?.read) ||
|
||||
permission?.groups?.read,
|
||||
);
|
||||
|
||||
return (
|
||||
<RestrictedAccess page="Playground identities" hasAccess={canReadPrincipal}>
|
||||
<PeersProvider>
|
||||
<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/playground"
|
||||
label="Playground"
|
||||
active
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1>Playground</h1>
|
||||
<Paragraph>
|
||||
Emulate a peer-backed user or a synthetic group and inspect the
|
||||
real policy, provider, accounting, and response path.
|
||||
</Paragraph>
|
||||
<div className="mt-6">
|
||||
<AgentNetworkPlayground />
|
||||
</div>
|
||||
</div>
|
||||
</AIProvidersProvider>
|
||||
</PeersProvider>
|
||||
</RestrictedAccess>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentNetworkPlaygroundPage() {
|
||||
const { permission } = usePermissions();
|
||||
return (
|
||||
<PageContainer>
|
||||
<RestrictedAccess
|
||||
page="Agent Network Playground"
|
||||
hasAccess={permission?.["agent_network.providers"]?.create}
|
||||
>
|
||||
<PlaygroundPageContent />
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
@@ -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 |
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -46,4 +46,5 @@ export interface PeerLocalFlags {
|
||||
rosenpass_enabled: boolean;
|
||||
rosenpass_permissive: boolean;
|
||||
server_ssh_allowed: boolean;
|
||||
server_vnc_allowed: boolean;
|
||||
}
|
||||
@@ -1,19 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { ScrollArea } from "@components/ScrollArea";
|
||||
import { SmallBadge } from "@components/ui/SmallBadge";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { isNetBirdCloud } from "@utils/netbird";
|
||||
import * as React from "react";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import ActivityIcon from "@/assets/icons/ActivityIcon";
|
||||
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
|
||||
import ControlCenterIcon from "@/assets/icons/ControlCenterIcon";
|
||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||
import DocsIcon from "@/assets/icons/DocsIcon";
|
||||
import IntegrationIcon from "@/assets/icons/IntegrationIcon";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||
import SettingsIcon from "@/assets/icons/SettingsIcon";
|
||||
import TeamIcon from "@/assets/icons/TeamIcon";
|
||||
import { DistributorNavigation } from "@/cloud/distributor/DistributorNavigation";
|
||||
@@ -28,6 +24,10 @@ import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode
|
||||
import { useMyAgentNetworkSetup } from "@/modules/agent-network/useMyAgentNetworkSetup";
|
||||
import { NavigationUsageInfo } from "@/modules/billing/NavigationUsageInfo";
|
||||
import { NetworkNavigation } from "@/modules/networks/misc/NetworkNavigation";
|
||||
import { SmallBadge } from "@components/ui/SmallBadge";
|
||||
import * as React from "react";
|
||||
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||
import ActivityIcon from "@/assets/icons/ActivityIcon";
|
||||
|
||||
type Props = {
|
||||
fullWidth?: boolean;
|
||||
@@ -260,16 +260,6 @@ export default function Navigation({
|
||||
permission?.["agent_network.providers"]?.read
|
||||
}
|
||||
/>
|
||||
<SidebarItem
|
||||
label="Playground"
|
||||
isChild
|
||||
href={"/agent-network/playground"}
|
||||
exactPathMatch={true}
|
||||
visible={
|
||||
agentNetworkSurface &&
|
||||
permission?.["agent_network.providers"]?.create
|
||||
}
|
||||
/>
|
||||
<SidebarItem
|
||||
label="Policies"
|
||||
isChild
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Callout } from "@components/Callout";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { usePeers } from "@/contexts/PeersProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
import { useAIProviders } from "@/modules/agent-network/AIProvidersProvider";
|
||||
import { PlaygroundRequest, PlaygroundResponse, usePlaygroundApi } from "./api";
|
||||
import PlaygroundPrincipalSelector, {
|
||||
PlaygroundPrincipalSelection,
|
||||
} from "./PlaygroundPrincipalSelector";
|
||||
import PlaygroundRequestEditor, {
|
||||
DEFAULT_PLAYGROUND_REQUEST,
|
||||
RawPlaygroundRequest,
|
||||
validatePlaygroundRequest,
|
||||
} from "./PlaygroundRequestEditor";
|
||||
import PlaygroundResult from "./PlaygroundResult";
|
||||
|
||||
export default function AgentNetworkPlayground() {
|
||||
const { permission } = usePermissions();
|
||||
const { users = [], isLoading: usersLoading } = useUsers();
|
||||
const { peers = [], isLoading: peersLoading } = usePeers();
|
||||
const { groups = [], isLoading: groupsLoading } = useGroups();
|
||||
const { settings, settingsLoading } = useAIProviders();
|
||||
const api = usePlaygroundApi();
|
||||
const canUsePeer = Boolean(
|
||||
permission?.peers?.read && permission?.users?.read,
|
||||
);
|
||||
const canUseGroup = Boolean(permission?.groups?.read);
|
||||
const canReadSettings = Boolean(permission?.["agent_network.settings"]?.read);
|
||||
const [principal, setPrincipal] = useState<PlaygroundPrincipalSelection>({
|
||||
mode: canUsePeer ? "peer" : "group",
|
||||
});
|
||||
const [request, setRequest] = useState<RawPlaygroundRequest>(
|
||||
DEFAULT_PLAYGROUND_REQUEST,
|
||||
);
|
||||
const [result, setResult] = useState<PlaygroundResponse>();
|
||||
const [error, setError] = useState<string>();
|
||||
const [running, setRunning] = useState(false);
|
||||
const controllerRef = useRef<AbortController | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (principal.mode === "peer" && !canUsePeer && canUseGroup) {
|
||||
setPrincipal({ mode: "group" });
|
||||
} else if (principal.mode === "group" && !canUseGroup && canUsePeer) {
|
||||
setPrincipal({ mode: "peer" });
|
||||
}
|
||||
}, [canUseGroup, canUsePeer, principal.mode]);
|
||||
|
||||
const validationError = useMemo(
|
||||
() => validatePlaygroundRequest(request),
|
||||
[request],
|
||||
);
|
||||
const principalID =
|
||||
principal.mode === "peer" ? principal.peer?.id : principal.group?.id;
|
||||
const loading = usersLoading || peersLoading || groupsLoading;
|
||||
const canRun =
|
||||
!running && !loading && Boolean(principalID) && !validationError;
|
||||
|
||||
const run = async () => {
|
||||
if (!canRun || !principalID) return;
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
setRunning(true);
|
||||
setError(undefined);
|
||||
const payload: PlaygroundRequest = {
|
||||
principal: { kind: principal.mode, id: principalID },
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
headers: request.headers.map((header) => ({
|
||||
name: header.name,
|
||||
values: [header.value],
|
||||
})),
|
||||
body: request.body,
|
||||
};
|
||||
try {
|
||||
const response = await api.post(payload, "", {
|
||||
signal: controller.signal,
|
||||
});
|
||||
setResult(response);
|
||||
} catch (cause) {
|
||||
if (!controller.signal.aborted) {
|
||||
const apiError = cause as { message?: string; requestId?: string };
|
||||
setError(
|
||||
`${apiError.message || "Playground request failed."}${
|
||||
apiError.requestId ? ` Request ID: ${apiError.requestId}` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (controllerRef.current === controller) {
|
||||
controllerRef.current = undefined;
|
||||
}
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
{canReadSettings && !settings && !settingsLoading && (
|
||||
<div role="alert">
|
||||
<Callout variant="warning">
|
||||
Configure the Agent Network endpoint and connect a
|
||||
playground-capable proxy before running a request.
|
||||
</Callout>
|
||||
</div>
|
||||
)}
|
||||
<Callout variant="warning">
|
||||
This sends a real provider request. It can incur cost and updates usage,
|
||||
budgets, and access logs for the emulated identity.
|
||||
</Callout>
|
||||
|
||||
<div className="grid grid-cols-1 items-stretch gap-6 xl:grid-cols-2">
|
||||
<div
|
||||
role="region"
|
||||
aria-label="Request builder"
|
||||
className="flex min-w-0 flex-col gap-6"
|
||||
>
|
||||
<PlaygroundPrincipalSelector
|
||||
value={principal}
|
||||
onChange={setPrincipal}
|
||||
users={users}
|
||||
peers={peers}
|
||||
groups={groups}
|
||||
canUsePeer={canUsePeer}
|
||||
canUseGroup={canUseGroup}
|
||||
disabled={running}
|
||||
/>
|
||||
|
||||
<PlaygroundRequestEditor
|
||||
value={request}
|
||||
onChange={setRequest}
|
||||
disabled={running}
|
||||
error={validationError}
|
||||
running={running}
|
||||
canRun={canRun}
|
||||
onRun={run}
|
||||
onCancel={() => controllerRef.current?.abort()}
|
||||
/>
|
||||
</div>
|
||||
<PlaygroundResult result={result} running={running} error={error} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,423 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@components/Button";
|
||||
import ButtonGroup from "@components/ButtonGroup";
|
||||
import { Callout } from "@components/Callout";
|
||||
import Card from "@components/Card";
|
||||
import { DropdownInfoText } from "@components/DropdownInfoText";
|
||||
import { DropdownInput } from "@components/DropdownInput";
|
||||
import { Label } from "@components/Label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@components/Select";
|
||||
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
|
||||
import { UserListItem } from "@components/UserSelector";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { type ChangeEvent, useState } from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { User } from "@/interfaces/User";
|
||||
import { PlaygroundPrincipalKind } from "./api";
|
||||
|
||||
export type PlaygroundPrincipalSelection = {
|
||||
mode: PlaygroundPrincipalKind;
|
||||
user?: User;
|
||||
peer?: Peer;
|
||||
group?: Group;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
value: PlaygroundPrincipalSelection;
|
||||
onChange: (value: PlaygroundPrincipalSelection) => void;
|
||||
users: User[];
|
||||
peers: Peer[];
|
||||
groups: Group[];
|
||||
canUsePeer: boolean;
|
||||
canUseGroup: boolean;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
function UserPicker({
|
||||
value,
|
||||
users,
|
||||
disabled,
|
||||
onSelect,
|
||||
}: Readonly<{
|
||||
value?: User;
|
||||
users: User[];
|
||||
disabled: boolean;
|
||||
onSelect: (user: User) => void;
|
||||
}>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filteredUsers = users.filter((user) =>
|
||||
`${user.name} ${user.email ?? ""}`.toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
|
||||
const triggerLabel =
|
||||
value?.name || (users.length ? "Select a user..." : "No users available.");
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) setQuery("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id="playground-user"
|
||||
variant="input"
|
||||
aria-label={triggerLabel}
|
||||
disabled={disabled}
|
||||
className="w-full min-w-0 justify-between text-left"
|
||||
>
|
||||
{value ? (
|
||||
<UserListItem user={value} variant="selected" />
|
||||
) : (
|
||||
<span className="min-w-0 truncate">{triggerLabel}</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
size={15}
|
||||
className="shrink-0 text-nb-gray-500"
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
>
|
||||
<DropdownInput
|
||||
aria-label="Search users"
|
||||
value={query}
|
||||
onChange={(nextQuery: string | ChangeEvent<HTMLInputElement>) =>
|
||||
setQuery(
|
||||
typeof nextQuery === "string"
|
||||
? nextQuery
|
||||
: nextQuery.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Search users..."
|
||||
hideEnterIcon
|
||||
/>
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Users"
|
||||
className="max-h-64 overflow-y-auto p-1"
|
||||
>
|
||||
{filteredUsers.length ? (
|
||||
filteredUsers.map((user) => {
|
||||
const selected = user.id === value?.id;
|
||||
return (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onClick={() => {
|
||||
onSelect(user);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left outline-none hover:bg-nb-gray-910 focus-visible:bg-nb-gray-910"
|
||||
>
|
||||
<UserListItem user={user} />
|
||||
{selected && (
|
||||
<Check
|
||||
aria-hidden
|
||||
size={15}
|
||||
className="shrink-0 text-netbird"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<DropdownInfoText className="mb-4 px-3">
|
||||
No users available.
|
||||
</DropdownInfoText>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupPicker({
|
||||
value,
|
||||
groups,
|
||||
disabled,
|
||||
onSelect,
|
||||
}: Readonly<{
|
||||
value?: Group;
|
||||
groups: Group[];
|
||||
disabled: boolean;
|
||||
onSelect: (group: Group) => void;
|
||||
}>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const selectableGroups = groups.filter((group) => Boolean(group.id));
|
||||
const filteredGroups = selectableGroups.filter((group) =>
|
||||
group.name.toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
const triggerLabel = value?.name
|
||||
? value.name
|
||||
: selectableGroups.length
|
||||
? "Select a group..."
|
||||
: "No groups available.";
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) setQuery("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id="playground-group"
|
||||
variant="input"
|
||||
aria-label={triggerLabel}
|
||||
disabled={disabled}
|
||||
className="w-full min-w-0 justify-between text-left"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2 truncate">
|
||||
{value && (
|
||||
<GroupBadgeIcon id={value.id} issued={value.issued} size={14} />
|
||||
)}
|
||||
<span className="truncate">{triggerLabel}</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
size={15}
|
||||
className="shrink-0 text-nb-gray-500"
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
>
|
||||
<DropdownInput
|
||||
aria-label="Search groups"
|
||||
value={query}
|
||||
onChange={(nextQuery: string | ChangeEvent<HTMLInputElement>) =>
|
||||
setQuery(
|
||||
typeof nextQuery === "string"
|
||||
? nextQuery
|
||||
: nextQuery.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Search groups..."
|
||||
hideEnterIcon
|
||||
/>
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Groups"
|
||||
className="max-h-64 overflow-y-auto p-1"
|
||||
>
|
||||
{filteredGroups.length ? (
|
||||
filteredGroups.map((group) => {
|
||||
const selected = group.id === value?.id;
|
||||
const peerCount = group.peers_count ?? group.peers?.length ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onClick={() => {
|
||||
onSelect(group);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left outline-none hover:bg-nb-gray-910 focus-visible:bg-nb-gray-910"
|
||||
>
|
||||
<GroupBadgeIcon
|
||||
id={group.id}
|
||||
issued={group.issued}
|
||||
size={14}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-nb-gray-100">
|
||||
{group.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-nb-gray-500">
|
||||
{peerCount} {peerCount === 1 ? "peer" : "peers"}
|
||||
</span>
|
||||
{selected && (
|
||||
<Check
|
||||
aria-hidden
|
||||
size={15}
|
||||
className="shrink-0 text-netbird"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<DropdownInfoText className="mb-4 px-3">
|
||||
No groups available.
|
||||
</DropdownInfoText>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlaygroundPrincipalSelector({
|
||||
value,
|
||||
onChange,
|
||||
users,
|
||||
peers,
|
||||
groups,
|
||||
canUsePeer,
|
||||
canUseGroup,
|
||||
disabled,
|
||||
}: Readonly<Props>) {
|
||||
const userPeers = value.user
|
||||
? peers.filter((peer) => peer.user_id === value.user?.id)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<section className="min-w-0" aria-labelledby="playground-identity-heading">
|
||||
<Card className="w-full">
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<h2
|
||||
id="playground-identity-heading"
|
||||
className="shrink-0 text-sm font-semibold text-nb-gray-100"
|
||||
>
|
||||
Identity
|
||||
</h2>
|
||||
<div role="radiogroup" aria-label="Identity mode">
|
||||
<ButtonGroup>
|
||||
<ButtonGroup.Button
|
||||
role="radio"
|
||||
aria-checked={value.mode === "peer"}
|
||||
aria-describedby="peer-mode-description"
|
||||
variant={value.mode === "peer" ? "tertiary" : "secondary"}
|
||||
disabled={disabled || !canUsePeer}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
mode: "peer",
|
||||
user: value.user,
|
||||
peer: value.peer,
|
||||
})
|
||||
}
|
||||
>
|
||||
Peer-backed user
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
role="radio"
|
||||
aria-checked={value.mode === "group"}
|
||||
aria-describedby="group-mode-description"
|
||||
variant={value.mode === "group" ? "tertiary" : "secondary"}
|
||||
disabled={disabled || !canUseGroup}
|
||||
onClick={() =>
|
||||
onChange({ mode: "group", group: value.group })
|
||||
}
|
||||
>
|
||||
Synthetic group
|
||||
</ButtonGroup.Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<span id="peer-mode-description" className="sr-only">
|
||||
Applies per-user and group policy limits.
|
||||
</span>
|
||||
<span id="group-mode-description" className="sr-only">
|
||||
Exercises one group without a user identity.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-nb-gray-900 pt-4">
|
||||
{value.mode === "peer" ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<Label htmlFor="playground-user">User</Label>
|
||||
<UserPicker
|
||||
value={value.user}
|
||||
users={users}
|
||||
disabled={disabled || !canUsePeer || users.length === 0}
|
||||
onSelect={(user) =>
|
||||
onChange({ mode: "peer", user, peer: undefined })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<Label htmlFor="playground-peer">Peer</Label>
|
||||
<Select
|
||||
value={value.peer?.id ?? ""}
|
||||
disabled={disabled || !value.user || userPeers.length === 0}
|
||||
onValueChange={(peerID) =>
|
||||
onChange({
|
||||
mode: "peer",
|
||||
user: value.user,
|
||||
peer: userPeers.find((peer) => peer.id === peerID),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="playground-peer" aria-label="Peer">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
value.user && userPeers.length === 0
|
||||
? "This user has no peers"
|
||||
: "Select a peer..."
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userPeers.map((peer) => (
|
||||
<SelectItem
|
||||
key={peer.id}
|
||||
value={peer.id!}
|
||||
description={peer.ip}
|
||||
>
|
||||
{peer.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{value.user && userPeers.length === 0 && (
|
||||
<div className="sm:col-span-2">
|
||||
<Callout variant="warning">
|
||||
This user has no peers to emulate.
|
||||
</Callout>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-w-0">
|
||||
<Label htmlFor="playground-group">Group</Label>
|
||||
<GroupPicker
|
||||
value={value.group}
|
||||
groups={groups}
|
||||
disabled={
|
||||
disabled ||
|
||||
!canUseGroup ||
|
||||
!groups.some((group) => Boolean(group.id))
|
||||
}
|
||||
onSelect={(group) => onChange({ mode: "group", group })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{value.mode === "group" && (
|
||||
<Callout variant="info">
|
||||
Synthetic group identity: per-user limits do not apply.
|
||||
</Callout>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@components/Button";
|
||||
import { Callout } from "@components/Callout";
|
||||
import Card from "@components/Card";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@components/Select";
|
||||
import { Textarea } from "@components/Textarea";
|
||||
import { Loader2, Play, Plus, Square, Trash2 } from "lucide-react";
|
||||
|
||||
const MAX_REQUEST_BODY_BYTES = 1 << 20;
|
||||
const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
||||
const PROTECTED_HEADERS: Record<string, true> = {
|
||||
authorization: true,
|
||||
connection: true,
|
||||
"content-length": true,
|
||||
cookie: true,
|
||||
forwarded: true,
|
||||
host: true,
|
||||
"keep-alive": true,
|
||||
"proxy-authorization": true,
|
||||
"proxy-connection": true,
|
||||
te: true,
|
||||
trailer: true,
|
||||
"transfer-encoding": true,
|
||||
upgrade: true,
|
||||
};
|
||||
|
||||
export type PlaygroundHeaderRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type RawPlaygroundRequest = {
|
||||
method: "GET" | "POST";
|
||||
path: string;
|
||||
headers: PlaygroundHeaderRow[];
|
||||
body: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_PLAYGROUND_REQUEST: RawPlaygroundRequest = {
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
headers: [
|
||||
{ id: "content-type", name: "Content-Type", value: "application/json" },
|
||||
],
|
||||
body: JSON.stringify(
|
||||
{
|
||||
model: "",
|
||||
messages: [{ role: "user", content: "" }],
|
||||
stream: false,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
};
|
||||
|
||||
export function validatePlaygroundRequest(request: RawPlaygroundRequest) {
|
||||
const bodyBytes = new TextEncoder().encode(request.body).byteLength;
|
||||
if (bodyBytes > MAX_REQUEST_BODY_BYTES) {
|
||||
return "Request body must not exceed 1 MiB.";
|
||||
}
|
||||
if (!request.path.startsWith("/") || request.path.startsWith("//")) {
|
||||
return "Path must be origin-form and start with /.";
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(request.path, "https://playground.invalid");
|
||||
if (parsed.origin !== "https://playground.invalid") {
|
||||
return "Path must not contain a scheme or host.";
|
||||
}
|
||||
} catch {
|
||||
return "Path is not a valid origin-form URL.";
|
||||
}
|
||||
for (const header of request.headers) {
|
||||
if (!HEADER_NAME.test(header.name)) {
|
||||
return `Header name ${header.name || "(empty)"} is invalid.`;
|
||||
}
|
||||
const lowerName = header.name.toLowerCase();
|
||||
if (PROTECTED_HEADERS[lowerName] || lowerName.startsWith("x-forwarded-")) {
|
||||
return `Header ${header.name} is managed by NetBird and cannot be set.`;
|
||||
}
|
||||
if (header.value.includes("\r") || header.value.includes("\n")) {
|
||||
return `Header ${header.name} contains an invalid line break.`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
value: RawPlaygroundRequest;
|
||||
onChange: (value: RawPlaygroundRequest) => void;
|
||||
disabled: boolean;
|
||||
error?: string;
|
||||
running: boolean;
|
||||
canRun: boolean;
|
||||
onRun: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export default function PlaygroundRequestEditor({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
error,
|
||||
running,
|
||||
canRun,
|
||||
onRun,
|
||||
onCancel,
|
||||
}: Readonly<Props>) {
|
||||
const updateHeader = (
|
||||
index: number,
|
||||
field: "name" | "value",
|
||||
next: string,
|
||||
) => {
|
||||
onChange({
|
||||
...value,
|
||||
headers: value.headers.map((header, headerIndex) =>
|
||||
headerIndex === index ? { ...header, [field]: next } : header,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="min-w-0" aria-labelledby="playground-request-heading">
|
||||
<Card className="w-full">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-nb-gray-900 px-4 py-3">
|
||||
<h2
|
||||
id="playground-request-heading"
|
||||
className="text-sm font-semibold text-nb-gray-100"
|
||||
>
|
||||
Raw request
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="xs"
|
||||
disabled={!canRun}
|
||||
onClick={onRun}
|
||||
>
|
||||
{running ? (
|
||||
<Loader2 aria-hidden size={15} className="animate-spin" />
|
||||
) : (
|
||||
<Play aria-hidden size={15} />
|
||||
)}
|
||||
Run live request
|
||||
</Button>
|
||||
{running && (
|
||||
<Button variant="danger-outline" size="xs" onClick={onCancel}>
|
||||
<Square aria-hidden size={14} /> Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-[7.5rem_minmax(0,1fr)]">
|
||||
<div>
|
||||
<Label htmlFor="playground-method">Method</Label>
|
||||
<Select
|
||||
value={value.method}
|
||||
disabled={disabled}
|
||||
onValueChange={(method) =>
|
||||
onChange({
|
||||
...value,
|
||||
method: method as "GET" | "POST",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="playground-method"
|
||||
aria-label="Method"
|
||||
className="font-mono"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<Label htmlFor="playground-path">Path</Label>
|
||||
<Input
|
||||
id="playground-path"
|
||||
aria-label="Path"
|
||||
className="font-mono"
|
||||
value={value.path}
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, path: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label as="div">Headers</Label>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...value,
|
||||
headers: [
|
||||
...value.headers,
|
||||
{ id: crypto.randomUUID(), name: "", value: "" },
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus aria-hidden size={14} /> Add header
|
||||
</Button>
|
||||
</div>
|
||||
{value.headers.map((header, index) => {
|
||||
const nameID = `playground-header-name-${header.id}`;
|
||||
const valueID = `playground-header-value-${header.id}`;
|
||||
return (
|
||||
<div
|
||||
key={header.id}
|
||||
className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,0.7fr)_minmax(0,1.3fr)_32px]"
|
||||
>
|
||||
<label htmlFor={nameID} className="sr-only">
|
||||
Header {index + 1} name
|
||||
</label>
|
||||
<Input
|
||||
id={nameID}
|
||||
aria-label={`Header ${index + 1} name`}
|
||||
className="min-w-0 font-mono"
|
||||
value={header.name}
|
||||
placeholder="Header name"
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
updateHeader(index, "name", event.target.value)
|
||||
}
|
||||
/>
|
||||
<label htmlFor={valueID} className="sr-only">
|
||||
Header {index + 1} value
|
||||
</label>
|
||||
<Input
|
||||
id={valueID}
|
||||
aria-label={`Header ${index + 1} value`}
|
||||
className="min-w-0 font-mono"
|
||||
value={header.value}
|
||||
placeholder="Header value"
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
updateHeader(index, "value", event.target.value)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
aria-label={`Remove header ${index + 1}`}
|
||||
variant="danger-outline"
|
||||
disabled={disabled}
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...value,
|
||||
headers: value.headers.filter(
|
||||
(_, headerIndex) => headerIndex !== index,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 aria-hidden size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="playground-body">Body</Label>
|
||||
<Textarea
|
||||
id="playground-body"
|
||||
aria-label="Body"
|
||||
className="min-h-[260px] overflow-auto font-mono text-xs leading-5"
|
||||
value={value.body}
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, body: event.target.value })
|
||||
}
|
||||
resize
|
||||
/>
|
||||
<div className="mt-1.5 text-right font-mono text-[10px] text-nb-gray-500">
|
||||
{new TextEncoder().encode(value.body).byteLength.toLocaleString()}{" "}
|
||||
/ {MAX_REQUEST_BODY_BYTES.toLocaleString()} bytes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div role="alert">
|
||||
<Callout variant="error">{error}</Callout>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Badge from "@components/Badge";
|
||||
import { Callout } from "@components/Callout";
|
||||
import Card from "@components/Card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { PlaygroundResponse } from "./api";
|
||||
|
||||
type Props = {
|
||||
result?: PlaygroundResponse;
|
||||
running: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function Value({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-nb-gray-250">
|
||||
{children || "—"}
|
||||
</dd>
|
||||
);
|
||||
}
|
||||
|
||||
function ResponseBody({
|
||||
result,
|
||||
running,
|
||||
}: Readonly<{ result?: PlaygroundResponse; running: boolean }>) {
|
||||
const body = result?.body || "(empty)";
|
||||
const lines = body.split("\n");
|
||||
|
||||
return (
|
||||
<TabsContent value="body" className="mt-0 min-h-0 flex-1 pt-0 outline-none">
|
||||
<div className="flex h-full min-h-0 flex-col gap-3 p-4">
|
||||
{result?.body_truncated && (
|
||||
<div role="alert">
|
||||
<Callout variant="warning">
|
||||
Response exceeded 8 MiB. Only the returned prefix is shown.
|
||||
</Callout>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-nb-gray-500">
|
||||
Response body
|
||||
</span>
|
||||
<span className="font-mono text-[10px] uppercase text-nb-gray-500">
|
||||
{result?.body_encoding || "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-h-64 min-w-0 flex-1 overflow-auto rounded-md border border-nb-gray-900 bg-nb-gray-950 py-3">
|
||||
{running && !result ? (
|
||||
<div className="flex h-full min-h-56 items-center justify-center gap-2 text-sm text-nb-gray-500">
|
||||
<Loader2 aria-hidden size={16} className="animate-spin" />
|
||||
Waiting for response…
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-w-max font-mono text-xs leading-5 text-nb-gray-250">
|
||||
{lines.map((line, index) => (
|
||||
<div
|
||||
key={`${index}-${line}`}
|
||||
className="grid min-w-0 grid-cols-[3rem_minmax(0,1fr)]"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="select-none border-r border-nb-gray-900 pr-3 text-right text-nb-gray-700"
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="min-w-0 whitespace-pre-wrap break-all px-3">
|
||||
{line || <span aria-hidden> </span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
||||
function ResponseHeaders({
|
||||
result,
|
||||
}: Readonly<{ result?: PlaygroundResponse }>) {
|
||||
const headers =
|
||||
result?.headers.flatMap((header) =>
|
||||
header.values.map((value) => ({ name: header.name, value })),
|
||||
) ?? [];
|
||||
|
||||
return (
|
||||
<TabsContent
|
||||
value="headers"
|
||||
className="mt-0 min-h-0 flex-1 pt-0 outline-none"
|
||||
>
|
||||
<div className="h-full min-h-64 overflow-auto p-4">
|
||||
{headers.length ? (
|
||||
<div className="overflow-hidden rounded-md border border-nb-gray-900">
|
||||
{headers.map((header, index) => (
|
||||
<div
|
||||
key={`${header.name}-${index}`}
|
||||
className="grid min-w-0 gap-1 border-b border-nb-gray-900 bg-nb-gray-950 px-3 py-2.5 last:border-b-0 sm:grid-cols-[minmax(8rem,0.65fr)_minmax(0,1fr)] sm:gap-4"
|
||||
>
|
||||
<span
|
||||
className={`break-all font-mono text-xs ${
|
||||
header.name.toLowerCase().startsWith("x-netbird-")
|
||||
? "text-netbird"
|
||||
: "text-nb-gray-400"
|
||||
}`}
|
||||
>
|
||||
{header.name}
|
||||
</span>
|
||||
<span className="min-w-0 break-all font-mono text-xs text-nb-gray-250">
|
||||
{header.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-nb-gray-900 bg-nb-gray-950 px-3 py-4 font-mono text-xs text-nb-gray-500">
|
||||
(none)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
||||
function ResponseDetails({
|
||||
result,
|
||||
}: Readonly<{ result?: PlaygroundResponse }>) {
|
||||
const groups =
|
||||
result?.identity.group_names
|
||||
.map(
|
||||
(name, index) => `${name} (${result.identity.group_ids[index] ?? "?"})`,
|
||||
)
|
||||
.join(", ") || result?.identity.group_ids.join(", ");
|
||||
|
||||
return (
|
||||
<TabsContent
|
||||
value="details"
|
||||
className="mt-0 min-h-0 flex-1 pt-0 outline-none"
|
||||
>
|
||||
<div className="grid gap-6 overflow-auto p-4 lg:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-nb-gray-400">
|
||||
Authoritative identity
|
||||
</h3>
|
||||
<dl className="grid grid-cols-[7rem_minmax(0,1fr)] gap-x-3 gap-y-2.5">
|
||||
<dt className="text-xs text-nb-gray-500">User ID</dt>
|
||||
<Value>{result?.identity.user_id}</Value>
|
||||
<dt className="text-xs text-nb-gray-500">User email</dt>
|
||||
<Value>{result?.identity.user_email}</Value>
|
||||
<dt className="text-xs text-nb-gray-500">Groups</dt>
|
||||
<Value>{groups}</Value>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-nb-gray-400">
|
||||
Policy attribution
|
||||
</h3>
|
||||
<dl className="grid grid-cols-[7.5rem_minmax(0,1fr)] gap-x-3 gap-y-2.5">
|
||||
<dt className="text-xs text-nb-gray-500">Reason</dt>
|
||||
<Value>{result?.policy.reason}</Value>
|
||||
<dt className="text-xs text-nb-gray-500">Provider surface</dt>
|
||||
<Value>{result?.policy.provider_surface}</Value>
|
||||
<dt className="text-xs text-nb-gray-500">Model</dt>
|
||||
<Value>{result?.policy.model}</Value>
|
||||
<dt className="text-xs text-nb-gray-500">Resolved provider</dt>
|
||||
<Value>{result?.policy.resolved_provider_id}</Value>
|
||||
<dt className="text-xs text-nb-gray-500">Selected policy</dt>
|
||||
<Value>{result?.policy.selected_policy_id}</Value>
|
||||
<dt className="text-xs text-nb-gray-500">Attribution group</dt>
|
||||
<Value>{result?.policy.attribution_group_id}</Value>
|
||||
<dt className="text-xs text-nb-gray-500">Authorising groups</dt>
|
||||
<Value>{result?.policy.authorising_group_ids.join(", ")}</Value>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlaygroundResult({
|
||||
result,
|
||||
running,
|
||||
error,
|
||||
}: Readonly<Props>) {
|
||||
const visibleResult = error ? undefined : result;
|
||||
const allowed = visibleResult?.policy.decision === "allow";
|
||||
const providerLabel = visibleResult
|
||||
? [
|
||||
visibleResult.policy.resolved_provider_id,
|
||||
visibleResult.policy.provider_surface,
|
||||
visibleResult.policy.model,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: "";
|
||||
|
||||
const status = error
|
||||
? "Request failed"
|
||||
: running
|
||||
? "Sending request…"
|
||||
: visibleResult
|
||||
? `HTTP ${visibleResult.status_code}`
|
||||
: "Not run";
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Response inspector"
|
||||
aria-live="polite"
|
||||
aria-busy={running}
|
||||
className="h-full min-h-80 min-w-0"
|
||||
>
|
||||
<Card className="flex h-full min-h-80 w-full flex-col">
|
||||
<div className="flex min-h-16 flex-wrap items-center justify-between gap-3 border-b border-nb-gray-900 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-nb-gray-100">Response</h2>
|
||||
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-2">
|
||||
{running && (
|
||||
<Loader2
|
||||
aria-hidden
|
||||
size={14}
|
||||
className="shrink-0 animate-spin text-netbird"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`font-mono text-xs font-medium ${
|
||||
error ? "text-red-400" : "text-nb-gray-100"
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
{visibleResult && !error && (
|
||||
<Badge size="xs" variant={allowed ? "green" : "red"}>
|
||||
{visibleResult.policy.decision || "unknown"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{visibleResult && !error && (
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-end gap-x-3 gap-y-1 font-mono text-[10px] uppercase text-nb-gray-500">
|
||||
<span>{visibleResult.body_encoding}</span>
|
||||
{providerLabel && (
|
||||
<span className="max-w-full break-all normal-case">
|
||||
{providerLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="flex min-h-64 flex-1 items-start p-4">
|
||||
<div role="alert" className="w-full">
|
||||
<Callout variant="error">{error}</Callout>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Tabs
|
||||
defaultValue="body"
|
||||
className={`flex min-h-0 flex-1 flex-col transition-opacity ${
|
||||
running && visibleResult ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<TabsList
|
||||
aria-label="Response views"
|
||||
justify="start"
|
||||
className="shrink-0 px-4 pt-3"
|
||||
>
|
||||
<TabsTrigger value="body">Body</TabsTrigger>
|
||||
<TabsTrigger value="headers">Headers</TabsTrigger>
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
</TabsList>
|
||||
<ResponseBody result={visibleResult} running={running} />
|
||||
<ResponseHeaders result={visibleResult} />
|
||||
<ResponseDetails result={visibleResult} />
|
||||
</Tabs>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Loaded 20 of 46 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user