Compare commits

...
Author SHA1 Message Date
pascal e2ed029fa8 disable billing 2026-09-11 18:25:05 +02:00
pascal e8ee2d1093 add certificate posturecheck support 2026-09-04 20:17:51 +02:00
Maycon Santos cba2613e68 Add anonymization level and upload URL to the debug bundle modal (#753)
Surfaces the two bundle parameters added to the management API: an
anonymization level (Default/Strict, shown when anonymization is enabled)
and an optional upload service URL that overrides the default upload
server. The upload URL is validated as an https URL before the job can be
submitted; both fields are omitted from the request when left at their
defaults so the peer applies its own.
2026-09-03 20:05:02 +02:00
Maycon Santos fe32179dc1 Pin the group view's group, and give agentgateway a reachable upstream (#788)
Stabilize the flaky Control Center and agentgateway e2e specs (#788)

  Three separate causes behind the recent e2e failures.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

No files matched your search

+1
View File
@@ -1,6 +1,7 @@
# Ignore zitadel environment
.env
Caddyfile
agentgateway-stub.Caddyfile
management.json
turnserver.conf
zitadel.env
+1 -1
View File
@@ -19,7 +19,7 @@ check_docker_compose() {
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
$DOCKER_COMPOSE_COMMAND down --volumes
rm -f docker-compose.yml Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env
rm -f docker-compose.yml Caddyfile agentgateway-stub.Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env
rm -rf proxy-certs proxy-certs-no-ports
rm -f ../../.test-config.json ../playwright.env.json
rm -f ../fixtures/auth/owner.json ../fixtures/auth/user.json
+35 -1
View File
@@ -532,7 +532,7 @@ initEnvironment() {
echo "Generated files already exist, if you want to reinitialize the environment, please remove them first."
echo "You can use the following commands:"
echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes"
echo " rm -f docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports"
echo " rm -f docker-compose.yml Caddyfile agentgateway-stub.Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports"
echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard."
exit 1
fi
@@ -540,6 +540,7 @@ initEnvironment() {
echo Rendering initial files...
renderDockerCompose > docker-compose.yml
renderCaddyfile > Caddyfile
renderAgentGatewayStub > agentgateway-stub.Caddyfile
renderZitadelEnv > zitadel.env
echo "" > turnserver.conf
echo "" > management.json
@@ -612,6 +613,30 @@ initEnvironment() {
echo "Password: $ZITADEL_ADMIN_PASSWORD" | tee -a .env
}
# Saving an Agent Network provider makes management call the upstream and
# refuses the save unless it answers a model listing, so a provider spec needs
# a reachable OpenAI-compatible endpoint. agentgateway is self-hosted and has
# no public one; this stub stands in for it, inside the compose network where
# management can reach it and without depending on a vendor being up.
renderAgentGatewayStub() {
cat <<'EOF'
{
admin off
auto_https off
}
:8088 {
handle /v1/models {
header Content-Type application/json
respond `{"object":"list","data":[{"id":"agentgateway-e2e-model","object":"model","owned_by":"netbird-e2e"}]}` 200
}
handle {
respond 404
}
}
EOF
}
renderCaddyfile() {
cat <<EOF
{
@@ -818,6 +843,15 @@ services:
volumes:
- netbird_caddy_data:/data
- ./Caddyfile:/etc/caddy/Caddyfile
# Stand-in upstream for the Agent Network provider specs: answers the model
# listing management probes before it accepts a provider save. Reachable
# only from inside the compose network, at http://agentgateway-stub:8088.
agentgateway-stub:
image: caddy
restart: unless-stopped
networks: [ netbird ]
volumes:
- ./agentgateway-stub.Caddyfile:/etc/caddy/Caddyfile
# Management
management:
image: ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG}
+25
View File
@@ -24,6 +24,31 @@ export async function openControlCenter(page: Page, tab?: FlowView) {
await dismissBlockingOverlays(page);
}
/**
* Opens the groups view already showing `groupName`.
*
* The view picks its own group on first render (getFirstGroup: the highest
* peers_count that is a policy source), and that ranking runs over every group
* in the account — including ones a spec on the other worker seeded moments
* ago. Deleting the spec's own fixtures first is not enough to pin it, so
* select the group by name and let the view rebuild around it.
*/
export async function openGroupView(page: Page, groupName: string) {
await openControlCenter(page, "groups");
const selector = canvasNode(page, "select-group-node");
await expect(selector).toBeVisible();
// Unforced on purpose: the opening fitView animates the canvas, and the
// actionability check is what waits for the node to stop moving.
await selector.click();
await page.getByTestId("select-dropdown-search").fill(groupName);
await page
.locator('[role="option"]')
.filter({ hasText: groupName })
.first()
.click({ force: true });
await expect(selector).toContainText(groupName);
}
export async function switchFlowView(page: Page, view: FlowView) {
await dismissBlockingOverlays(page);
await page.getByTestId(`cc-flow-${view}`).click({ force: true });
@@ -0,0 +1,156 @@
/**
* Agent Network agentgateway provider spec.
*
* Exercises the catalog-driven provider flow against a management build that
* includes the agentgateway catalog entry. Older builds skip the test.
*/
import {
deleteAgentNetworkProvidersByPrefix,
listAgentNetworkCatalog,
supportsAgentNetworkSettingsBootstrap,
} from "../helpers/api";
import { navigateTo } from "../helpers/auth";
import { expect, test } from "../helpers/fixtures";
import { generateRandomName } from "../helpers/utils";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
const AGENTGATEWAY_CATALOG_ID = "agentgateway";
const PROVIDER_PREFIX = "e2e-agentgateway-";
test.describe
.serial("Agent Network agentgateway provider @agent-network", () => {
test("connect agentgateway with its trusted identity mapping", async ({
dashboardAsOwner: page,
}) => {
await page.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch {}
},
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
);
const catalog = await listAgentNetworkCatalog(page);
test.skip(
!catalog.some((entry) => entry.id === AGENTGATEWAY_CATALOG_ID),
`management catalog has no ${AGENTGATEWAY_CATALOG_ID} entry`,
);
test.skip(
!(await supportsAgentNetworkSettingsBootstrap(page)),
"management build does not support Agent Network settings bootstrap",
);
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
try {
await navigateTo(page, "/agent-network/providers");
await page
.getByTestId("connect-agent-network-provider")
.first()
.click({ force: true });
await page
.getByTestId("agent-network-provider-type")
.click({ force: true });
await page
.getByTestId("select-dropdown-search")
.fill(AGENTGATEWAY_CATALOG_ID);
await page
.getByTestId("agent-network-provider-option-agentgateway")
.click({ force: true });
// Saving a provider makes management call the upstream, and it refuses
// the save with a 422 unless the upstream answers a model listing — so a
// made-up host cannot be used here. agentgateway is self-hosted and has
// no public endpoint to point at, so create-test-env.sh runs a stub that
// answers that probe inside the compose network.
const upstreamURL = "http://agentgateway-stub:8088";
await page
.getByTestId("agent-network-provider-upstream-url")
.fill(upstreamURL);
await page
.getByTestId("agent-network-provider-api-key")
.fill("e2e-agentgateway-virtual-key");
const providerName = generateRandomName(PROVIDER_PREFIX);
await page.getByTestId("agent-network-provider-name").fill(providerName);
await page
.getByTestId("agent-network-provider-models-tab")
.click({ force: true });
await expect(
page.getByTestId("agent-network-provider-models-help"),
).toContainText("Empty = all catalog models");
await page
.getByTestId("agent-network-provider-continue")
.click({ force: true });
await expect(
page.getByTestId("agent-network-provider-mappings-tab"),
).toHaveAttribute("data-state", "active");
await expect(
page.getByTestId("agent-network-provider-user-mapping"),
).toContainText("x-netbird-user-id");
await expect(
page.getByTestId("agent-network-provider-groups-mapping"),
).toContainText("x-netbird-groups");
await expect(
page.getByTestId("agent-network-provider-groups-guidance"),
).toContainText("must not be used as an agentgateway authorization claim");
const createResponse = page.waitForResponse(
(response) =>
response.url().includes("/agent-network/providers") &&
response.request().method() === "POST",
{ timeout: 30_000 },
);
await page.getByTestId("agent-network-provider-submit").click({
force: true,
});
const created = await createResponse;
expect([200, 201]).toContain(created.status());
const body = created.request().postDataJSON();
expect(body).toMatchObject({
provider_id: AGENTGATEWAY_CATALOG_ID,
name: providerName,
upstream_url: upstreamURL,
models: [],
metadata_disabled: false,
});
expect(body).not.toHaveProperty("identity_header_user_id");
expect(body).not.toHaveProperty("identity_header_groups");
await expect(
page.getByTestId("agent-network-provider-modal"),
).toBeHidden();
await expect(page.getByTestId(providerName)).toBeVisible();
await expect(
page.getByTestId(`provider-models-${providerName}`),
).toHaveText("All Models");
await page.getByTestId(providerName).click({ force: true });
await expect(
page.getByTestId("agent-network-provider-modal"),
).toBeVisible();
await expect(
page.getByTestId("agent-network-provider-upstream-url"),
).toHaveValue(upstreamURL);
await page
.getByTestId("agent-network-provider-mappings-tab")
.click({ force: true });
await expect(
page.getByTestId("agent-network-provider-user-mapping"),
).toContainText("x-netbird-user-id");
await expect(
page.getByTestId("agent-network-provider-groups-mapping"),
).toContainText("x-netbird-groups");
await page
.getByTestId("agent-network-provider-modal")
.getByTestId("modal-close")
.click({ force: true });
} finally {
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
}
});
});
@@ -0,0 +1,195 @@
/**
* Agent Network provider save: what the operator sees when the backend refuses.
*
* Saving a provider checks its upstream URL and credential against the vendor
* before storing anything, so a 422 naming the field to correct is an ordinary
* outcome of the form rather than a server fault. Four things have to hold,
* and each one has been wrong at some point:
*
* - while the check runs the submit says so and refuses a second click. The
* vendor call is the slow part and an unreachable upstream drags it out to
* a timeout, which looked exactly like a button that had not registered
* the first click.
* - exactly ONE toast, the shared "Request failed with status code N", which
* quotes the API's own sentence. A second toast from the save path said the
* same thing under a vaguer title.
* - the toast is styled as a failure. notify() renders green with a check
* mark unless told otherwise, so a refusal announced itself as a success.
* - the modal stays open with the typed values intact. The API never returns
* an API key, so closing the form loses it with nowhere to retype it.
*
* The refusal is mocked rather than provoked: the backend check ships with a
* management build these tests do not pin, and what is under test here is the
* dashboard's handling of the response, not the vendor call that produces it.
*
* Like the other agent-network specs, this one builds its own context rather
* than taking the shared dashboardAsOwner fixture: the Agent Network menu is
* deployment-gated behind a localStorage override that has to be in place
* before the first navigation, which addInitScript on an own context is the
* way to do. The route interception below is a second reason — the shared page
* is worker-scoped, and a 422 left on it would follow every later test.
*/
import { type Browser, expect, type Page, test } from "@playwright/test";
import { loginToApp, navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
const PROVIDERS_ENDPOINT = /\/api\/agent-network\/providers(\?|$)/;
const PROVIDER_PREFIX = "e2e-refused-";
const TITLE_TESTID = "notification-title";
// The message a refused save carries: the backend names which of the two
// fields is at fault, without a status code and without echoing the URL.
const REFUSAL = "the upstream url could not be reached: no such host";
// Matched case-insensitively on purpose. The backend lowercases its messages
// (WriteError does, and the copy is written for it) while the toast uppercases
// the first character before rendering. Asserting either spelling would pin
// the test to that transform rather than to the sentence the operator reads.
const REFUSAL_TEXT = new RegExp(
REFUSAL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
"i",
);
async function newAgentNetworkPage(browser: Browser): Promise<{
page: Page;
close: () => Promise<void>;
}> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
await context.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch (e) {}
},
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
);
const page = await context.newPage();
await loginToApp(page, "owner");
return { page, close: () => context.close() };
}
// How long the mocked create takes to answer. The real check is a vendor round
// trip, so a save is never instant; holding the response gives the in-flight
// assertions a window to run in without racing the toast.
const REFUSAL_DELAY_MS = 1500;
// refuseProviderCreate answers the create with the 422 the credential check
// produces. Only POST is intercepted: the page still lists providers, and the
// settings bootstrap that may precede the create is left alone.
async function refuseProviderCreate(page: Page) {
await page.route(PROVIDERS_ENDPOINT, async (route) => {
if (route.request().method() !== "POST") return route.continue();
const origin = route.request().headers()["origin"] || "*";
await new Promise((resolve) => setTimeout(resolve, REFUSAL_DELAY_MS));
await route.fulfill({
status: 422,
headers: {
"content-type": "application/json",
"access-control-allow-origin": origin,
},
body: JSON.stringify({ code: 422, message: REFUSAL }),
});
});
}
test.describe
.serial("Agent Network refused provider save @agent-network", () => {
test("reports the refusal once and keeps the form open", async ({
browser,
}) => {
const { page, close } = await newAgentNetworkPage(browser);
try {
await refuseProviderCreate(page);
// navigateTo rather than goto: it dismisses the setup modal that greets a
// fresh account and clears the scroll lock it leaves behind, either of
// which swallows the clicks below.
await navigateTo(page, "/agent-network/providers");
await page
.getByRole("button", { name: "Connect Provider" })
.first()
.click({ force: true });
const providerName = generateRandomName(PROVIDER_PREFIX);
await page
.getByTestId("agent-network-provider-name")
.fill(providerName);
await page
.getByTestId("agent-network-provider-api-key")
.fill("sk-e2e-refused-key");
// The submit lives on the Models tab — the Provider tab's primary button
// only advances to it.
await page.getByRole("tab", { name: "Models" }).click({ force: true });
const submit = page.getByTestId("agent-network-provider-submit");
await expect(submit).toBeEnabled();
const refusal = page.waitForResponse(
(resp) =>
PROVIDERS_ENDPOINT.test(resp.url()) &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await submit.click({ force: true });
// ---- the wait is visible while it lasts ----
// A second create is not idempotent, so the button has to say it is
// working and stop taking clicks rather than sit there looking untouched.
// Asserted before the response is awaited: the mock holds it open for
// exactly this window.
await expect(submit).toContainText("Connecting provider");
await expect(submit).toBeDisabled();
expect((await refusal).status()).toBe(422);
// ---- the toast says what the API said ----
const title = page.getByTestId(TITLE_TESTID).first();
await expect(title).toContainText("Request failed with status code 422");
await expect(
page.locator("[data-toast-notification]").first(),
).toContainText(REFUSAL_TEXT);
// ---- and only that toast ----
// The save path used to add its own on top, so the count is the
// assertion rather than the presence of the right one.
await expect(page.locator("[data-toast-notification]")).toHaveCount(1);
await expect(page.getByText("Failed to connect provider")).toHaveCount(0);
// ---- styled as a failure, not a success ----
// notify() paints the icon tile green with a check unless the caller
// says otherwise, which is how a refusal once looked like a success.
//
// A failure reaches the tile two ways — notify()'s own error state, and
// a caller passing its own colour and icon, which is what the shared
// request-failed toast does — so what has to hold is the negative: this
// is not the default success tile.
const icon = page.getByTestId("notification-icon");
await expect(icon).toBeVisible();
await expect(icon).not.toHaveAttribute("data-variant", "success");
// ---- the form is still there, still holding what was typed ----
// The submit only exists while the modal is open, so its presence is the
// check that nothing closed underneath the toast. It also has to come
// back out of the in-flight state, or the retry the toast asks for is
// impossible.
await expect(submit).toBeVisible();
await expect(submit).toBeEnabled();
await expect(submit).toContainText("Connect Provider");
await page.getByRole("tab", { name: "Provider" }).click({ force: true });
await expect(
page.getByTestId("agent-network-provider-name"),
).toHaveValue(providerName);
// The key matters most: the API never returns one, so a form that lost
// it leaves the operator with nothing to correct.
await expect(
page.getByTestId("agent-network-provider-api-key"),
).toHaveValue("sk-e2e-refused-key");
} finally {
await close();
}
});
});
@@ -364,13 +364,17 @@ test.describe.serial("Control Center Draft Matrix @control-center", () => {
}) => {
const group = await place(page, "group", 0.6, 0.5);
await expectChangeCount(page, 1);
// React Flow ignores delete keys while an input is focused, and the panel's
// search takes focus on click, so blur it before pressing Backspace.
await group.click();
await expect(group).toHaveClass(/selected/);
await page.evaluate(
() => (document.activeElement as HTMLElement | null)?.blur(),
);
// React Flow ignores delete keys while an input is focused, and the panel
// the click opens takes focus into its search — from an effect, about
// 100ms later. Blurring before that lands leaves the input to claim focus
// afterwards and swallow the Backspace, so wait for the focus, then drop
// it.
const panelSearch = page.locator("#cc-group-panel input").first();
await expect(panelSearch).toBeFocused();
await panelSearch.blur();
await expect(panelSearch).not.toBeFocused();
await page.keyboard.press("Backspace");
await expect(canvasNode(page, "group-new-")).toHaveCount(0);
await expectChangeCount(page, 0);
@@ -19,6 +19,7 @@ import {
dismissBlockingOverlays,
enterDraft,
openControlCenter,
openGroupView,
resetDraftState,
reviewButton,
} from "../helpers/control-center";
@@ -37,7 +38,6 @@ test.describe
await deleteGroupsByPrefix(page, PREFIX);
});
// Clean-slated so the draft group view's auto-select is deterministic.
async function seedPolicyGroupView(page: Page) {
await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX);
@@ -49,7 +49,9 @@ test.describe
src.id,
dst.id,
);
await openControlCenter(page, "groups");
// Selected by name: the view's own pick ranks every group in the account,
// so a peer-bearing group from another spec on the other worker wins it.
await openGroupView(page, src.name);
await enterDraft(page);
const policyNode = canvasNode(page, `policy-${policy.id}`);
await expect(policyNode).toBeVisible({ timeout: 15_000 });
+7 -14
View File
@@ -18,6 +18,7 @@ import {
clickContextMenuItem,
dismissBlockingOverlays,
openControlCenter,
openGroupView,
resetDraftState,
switchFlowView,
} from "../helpers/control-center";
@@ -47,8 +48,6 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
});
async function seedPolicyAndOpenGroupView(page: Page, enabled = true) {
// The group view auto-selects a group that HAS a policy, so stale policies
// would make the rendered group non-deterministic.
await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX);
@@ -64,15 +63,10 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
);
const policyNode = canvasNode(page, `policy-${policy.id}`);
// A stale SWR read or a build/fit race can fail to place the node.
for (let attempt = 0; attempt < 3; attempt++) {
await openControlCenter(page, "groups");
const shown = await policyNode
.waitFor({ state: "visible", timeout: 10_000 })
.then(() => true)
.catch(() => false);
if (shown) break;
}
// Selecting src by name rather than trusting the view's own pick: the
// auto-select ranks every group in the account, so a peer-bearing group
// another spec seeded on the other worker outranks this one.
await openGroupView(page, src.name);
await expect(policyNode).toBeVisible({ timeout: 10_000 });
return { src, dst, policy, policyNode };
}
@@ -512,7 +506,6 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
test("Should save group resource membership in live mode (correct payload)", async ({
dashboardAsOwner: page,
}) => {
// Clean slate so the group view deterministically lands on our group.
await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX);
@@ -529,7 +522,7 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
[],
);
await openControlCenter(page, "groups");
await openGroupView(page, src.name);
const groupNode = canvasNode(page, `group-${dst.id}`);
await expect(groupNode).toBeVisible({ timeout: 15_000 });
@@ -610,7 +603,7 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
);
await createPolicy(page, generateRandomName(PREFIX), src.id, d2.id);
await openControlCenter(page, "groups");
await openGroupView(page, src.name);
const policyNode = canvasNode(page, `policy-${p1.id}`);
await expect(policyNode).toBeVisible({ timeout: 15_000 });
await expect(
+16
View File
@@ -180,6 +180,22 @@ export default function Notification<T>({
>
<div className={"flex items-center gap-4"}>
<div
data-testid={"notification-icon"}
// Whether a toast reads as a success or a failure is carried by
// the tile's colour alone, which a test can only assert as a
// tailwind class. Naming the state says the same thing without
// pinning the palette. "success" is the default green tick
// specifically, so a caller that supplied its own colour is
// "custom" rather than folded in with it.
data-variant={
loading
? "loading"
: error
? "error"
: backgroundColor || icon
? "custom"
: "success"
}
className={classNames(
"h-8 w-8 shadow-sm text-white flex items-center justify-center rounded-md shrink-0",
loading
+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;
}
+5
View File
@@ -11,6 +11,7 @@ export interface PostureCheck {
geo_location_check?: GeoLocationCheck;
peer_network_range_check?: PeerNetworkRangeCheck;
process_check?: ProcessCheck;
certificate_check?: CertificateCheck;
};
policies?: Policy[];
active?: boolean;
@@ -65,6 +66,10 @@ export interface Process {
windows_path?: string;
}
export interface CertificateCheck {
ca_certificates: string[];
}
export const windowsKernelVersions: SelectOption[] = [
{ value: "10.0", label: "Windows 10" },
{ value: "10.0.2", label: "Windows 11" },
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,7 @@
"use client";
import { notify } from "@components/Notification";
import { IconCircleX } from "@tabler/icons-react";
import useFetchApi, { useApiCall } from "@utils/api";
import React, {
createContext,
@@ -217,6 +218,18 @@ function fromAPI(p: APIProvider): AIProvider {
};
}
// notify() renders green with a check mark unless it is told otherwise: its red
// styling comes from the promise path, and none of these use it. A failure that
// looks like a success is worse than saying nothing, so every failure toast in
// this file goes through here.
function notifyFailure(props: { title: string; description: string }) {
return notify({
...props,
backgroundColor: "bg-red-500",
icon: <IconCircleX size={20} />,
});
}
function toAPIModels(models: ProviderModel[]): APIProviderModel[] {
// undefined cache rates stay undefined so JSON.stringify omits the
// key: an omitted rate inherits NetBird's default, an explicit 0
@@ -497,7 +510,13 @@ type AIProvidersContextValue = {
closeWizard: () => void;
isWizardOpen: boolean;
addProvider: (input: ProviderConnectInput) => Promise<AIProvider | undefined>;
updateProvider: (id: string, updates: ProviderUpdateInput) => Promise<void>;
// Resolves false when the save was refused — the backend checks a provider's
// url and credential before storing them, so a rejected edit must leave the
// form open with what the operator typed still in it.
updateProvider: (
id: string,
updates: ProviderUpdateInput,
) => Promise<boolean>;
toggleProvider: (id: string) => Promise<void>;
deleteProvider: (id: string) => Promise<void>;
addPolicy: (
@@ -609,6 +628,11 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
(agentNetworkEnabled && !!permission?.["agent_network.providers"]?.read) ||
mySetupConfigured,
);
// Default error handling on purpose: a failed save raises the shared
// "Request failed with status code N" toast, which carries the message the
// API sent — for a refused provider that is the sentence naming the url or
// the credential. The save paths below stay silent on failure rather than
// adding a second toast that says the same thing in different words.
const providersApi = useApiCall<APIProvider>("/agent-network/providers");
const { data: apiPolicies, mutate: mutatePolicies } = useFetchApi<
@@ -688,21 +712,24 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
const addProvider = useCallback(
async (input: ProviderConnectInput) => {
let created: APIProvider;
try {
const created = await providersApi.post(toCreateRequest(input));
await mutate();
notify({
title: "AI provider connected",
description: `${created.name} is now available on your agent network endpoint.`,
});
return fromAPI(created);
} catch (err) {
notify({
title: "Failed to connect provider",
description: err instanceof Error ? err.message : String(err),
});
created = await providersApi.post(toCreateRequest(input));
} catch {
// Reported already by the shared request-failed toast. Returning
// undefined is what keeps the modal open on the fields to correct.
return undefined;
}
// Outside the catch: the provider exists from here on, and a failed
// revalidation is a stale list rather than a failed create. Reporting it
// as one would hold the modal open on a form whose next submit creates a
// second provider.
await mutate().catch(() => undefined);
notify({
title: "AI provider connected",
description: `${created.name} is now available on your agent network endpoint.`,
});
return fromAPI(created);
},
[providersApi, mutate],
);
@@ -710,7 +737,16 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
const updateProvider = useCallback(
async (id: string, updates: ProviderUpdateInput) => {
const existing = (apiProviders ?? []).find((p) => p.id === id);
if (!existing) return;
if (!existing) {
// The update merges onto the record as this browser last saw it, so a
// provider deleted elsewhere leaves nothing to merge onto. Silence
// here read as a save that did nothing.
notifyFailure({
title: "Provider not updated",
description: "This provider is no longer available. Reload the page.",
});
return false;
}
const merged: APIProviderRequest = {
provider_id: updates.providerId ?? existing.provider_id,
name: updates.name ?? existing.name,
@@ -737,17 +773,18 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
};
try {
await providersApi.put(merged, `/${id}`);
await mutate();
notify({
title: "Provider updated",
description: "Settings saved.",
});
} catch (err) {
notify({
title: "Failed to update provider",
description: err instanceof Error ? err.message : String(err),
});
} catch {
// Reported already by the shared request-failed toast.
return false;
}
// See addProvider: a failed revalidation is a stale list, not a failed
// write, and must not send the operator back to resubmit one.
await mutate().catch(() => undefined);
notify({
title: "Provider updated",
description: "Settings saved.",
});
return true;
},
[apiProviders, providersApi, mutate],
);
@@ -771,7 +808,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
description: "Endpoint will be torn down on next mapping update.",
});
} catch (err) {
notify({
notifyFailure({
title: "Failed to remove provider",
description: err instanceof Error ? err.message : String(err),
});
@@ -791,7 +828,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
});
return policyFromAPI(created);
} catch (err) {
notify({
notifyFailure({
title: "Failed to create policy",
description: err instanceof Error ? err.message : String(err),
});
@@ -827,7 +864,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
description: "Settings saved.",
});
} catch (err) {
notify({
notifyFailure({
title: "Failed to update policy",
description: err instanceof Error ? err.message : String(err),
});
@@ -855,7 +892,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
description: "Policy deleted.",
});
} catch (err) {
notify({
notifyFailure({
title: "Failed to remove policy",
description: err instanceof Error ? err.message : String(err),
});
@@ -875,7 +912,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
});
return guardrailFromAPI(created);
} catch (err) {
notify({
notifyFailure({
title: "Failed to create guardrail",
description: err instanceof Error ? err.message : String(err),
});
@@ -904,7 +941,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
description: "Settings saved.",
});
} catch (err) {
notify({
notifyFailure({
title: "Failed to update guardrail",
description: err instanceof Error ? err.message : String(err),
});
@@ -924,7 +961,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
"Existing policies still reference this guardrail until you detach it.",
});
} catch (err) {
notify({
notifyFailure({
title: "Failed to remove guardrail",
description: err instanceof Error ? err.message : String(err),
});
@@ -944,7 +981,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
});
return budgetRuleFromAPI(created);
} catch (err) {
notify({
notifyFailure({
title: "Failed to create global limit",
description: err instanceof Error ? err.message : String(err),
});
@@ -978,7 +1015,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
description: "Settings saved.",
});
} catch (err) {
notify({
notifyFailure({
title: "Failed to update global limit",
description: err instanceof Error ? err.message : String(err),
});
@@ -1006,7 +1043,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
description: "Global limit deleted.",
});
} catch (err) {
notify({
notifyFailure({
title: "Failed to remove global limit",
description: err instanceof Error ? err.message : String(err),
});
@@ -1025,7 +1062,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
} catch (err) {
const code = (err as { code?: number })?.code;
if (code !== 409) {
notify({
notifyFailure({
title: "Failed to set up the agent network endpoint",
description: err instanceof Error ? err.message : String(err),
});
@@ -1047,7 +1084,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
// row there is nothing to echo — and no row to update; the backend
// would 404 the PUT anyway.
if (!settings) {
notify({
notifyFailure({
title: "Failed to update account controls",
description: "Agent Network has not been set up yet.",
});
@@ -1062,7 +1099,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
});
return true;
} catch (err) {
notify({
notifyFailure({
title: "Failed to update account controls",
description: err instanceof Error ? err.message : String(err),
});
+1 -1
View File
@@ -1,4 +1,3 @@
/* eslint-disable */
// Mock data for the Agent Network section. No backend wired up yet.
// All numbers, IDs, and text are static placeholders for click-through.
@@ -14,6 +13,7 @@ export type AIProviderId =
| "mistral_api"
| "kimi_api"
| "litellm_proxy"
| "agentgateway"
| "portkey"
| "bifrost"
| "cloudflare_ai_gateway"
@@ -15,12 +15,12 @@ import React, { useState } from "react";
import AIAccessIcon from "@/assets/icons/AgentNetworkIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { AIProvider } from "@/modules/agent-network/data/mockData";
import { useAIProviders } from "@/modules/agent-network/AIProvidersProvider";
import AIProviderLogo from "@/modules/agent-network/AIProviderLogo";
import AIProviderModal from "@/modules/agent-network/AIProviderModal";
import { useProviderCatalog } from "@/modules/agent-network/useProviderCatalog";
import { useAIProviders } from "@/modules/agent-network/AIProvidersProvider";
import { AIProvider } from "@/modules/agent-network/data/mockData";
import AgentProviderActionCell from "@/modules/agent-network/table/AgentProviderActionCell";
import { useProviderCatalog } from "@/modules/agent-network/useProviderCatalog";
function NameCell({ provider }: { provider: AIProvider }) {
const { getById } = useProviderCatalog();
@@ -70,10 +70,20 @@ function NameCell({ provider }: { provider: AIProvider }) {
function ModelsCell({ provider }: { provider: AIProvider }) {
if (provider.models.length === 0) {
return <span className={"text-xs text-nb-gray-400"}>All Models</span>;
return (
<span
className={"text-xs text-nb-gray-400"}
data-testid={`provider-models-${provider.name}`}
>
All Models
</span>
);
}
return (
<span className={"text-xs text-nb-gray-300"}>
<span
className={"text-xs text-nb-gray-300"}
data-testid={`provider-models-${provider.name}`}
>
{provider.models.length} Models
</span>
);
@@ -212,7 +222,11 @@ const AddProviderButton = () => {
// button instead of a wizard that can only fail.
if (!permission?.["agent_network.providers"]?.create) return null;
return (
<Button variant={"primary"} onClick={openWizard}>
<Button
variant={"primary"}
onClick={openWizard}
data-testid={"connect-agent-network-provider"}
>
<PlusCircle size={16} />
Connect Provider
</Button>
@@ -1,6 +1,6 @@
"use client";
import { useApiCall } from "@utils/api";
import { type ErrorResponse, useApiCall } from "@utils/api";
import { useCallback, useRef, useState } from "react";
// DiscoveredModel is one model the vendor says this credential can reach.
@@ -27,6 +27,8 @@ type DiscoveryResponse = { models: DiscoveredModel[] };
type DiscoveryRequest = {
catalog_provider_id: string;
// Sent alongside provider_id, this overrides the record's stored upstream,
// which is how a retyped URL is listed against before it is saved.
upstream_url?: string;
// Exactly one of these. api_key is for a provider being typed in and not yet
// saved; provider_id reuses a saved record's stored credential, which is how
@@ -96,7 +98,13 @@ export function useDiscoveredModels() {
// 422 is the API saying this provider has no listing endpoint, which
// is a fact about the catalog entry rather than a failure — the caller
// keeps the catalog list and says so quietly.
const status = (e as { status?: number })?.status;
//
// useApiCall rejects with ErrorResponse, whose field is `code`; `status`
// is only read as a fallback for a raw fetch rejection. Reading `status`
// alone left this undefined on every API failure, so a provider with no
// listing endpoint was reported as unreachable instead of falling back.
const failure = e as Partial<ErrorResponse> & { status?: number };
const status = failure?.code ?? failure?.status;
setState({
models: [],
isLoading: false,
@@ -104,8 +112,7 @@ export function useDiscoveredModels() {
error:
status === 422
? undefined
: (e as { message?: string })?.message ??
"Could not reach the provider",
: failure?.message ?? "Could not reach the provider",
});
return [];
}
@@ -2,19 +2,19 @@ import { Edge, Node } from "@xyflow/react";
import { sortBy } from "lodash";
import { Group } from "@/interfaces/Group";
import { Policy } from "@/interfaces/Policy";
import { useControlCenterData } from "@/modules/control-center/hooks/useControlCenterData";
import {
addNode,
addEdge,
addNode,
DEFAULT_LAYOUT_CONFIG,
} from "@/modules/control-center/utils/graph-builder";
import { withFreshGroupCounts } from "@/modules/control-center/utils/helpers";
import { applyD3HierarchicalLayout } from "@/modules/control-center/utils/layouts";
import {
addAgentNetworkProviderNodes,
useAgentNetworkOverlay,
} from "./agent-network-overlay";
import { addDestinationResourceNodes, ViewResult } from "./types";
import { useControlCenterData } from "@/modules/control-center/hooks/useControlCenterData";
import { withFreshGroupCounts } from "@/modules/control-center/utils/helpers";
export function usePeerView() {
const { policies, peers, networks, networkResources, groups, isDataReady } =
@@ -39,6 +39,7 @@ export function usePeerView() {
(policiesOverride ?? policies!).filter((p) => {
const rule = p.rules?.[0];
if (!rule) return false;
if (rule.sourceResource?.id === peerId) return true;
const sources = rule.sources as Group[];
return sources?.some((d) => peerGroups?.some((pg) => pg.id === d.id));
}),
@@ -80,7 +81,6 @@ export function usePeerView() {
type: "smart",
data: { enabled, policy },
});
});
addDestinationResourceNodes(
+76 -2
View File
@@ -4,6 +4,7 @@ import {
FileText,
PlusCircle,
Shield,
UploadCloud,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
@@ -19,6 +20,13 @@ import {
} from "@/components/modal/Modal";
import ModalHeader from "@/components/modal/ModalHeader";
import { notify } from "@/components/Notification";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/Select";
import Separator from "@/components/Separator";
import { Workload } from "@/interfaces/Job";
import { useApiCall } from "@/utils/api";
@@ -36,10 +44,15 @@ export function CreateDebugJobModalContent({ peerID, onSuccess }: Props) {
const [bundleForTime, setBundleForTime] = useState<string>("");
const [logFileCount, setLogFileCount] = useState<string>("10");
const [anonymize, setAnonymize] = useState<boolean>(false);
const [anonymizeLevel, setAnonymizeLevel] = useState<"default" | "strict">(
"default",
);
const [uploadUrl, setUploadUrl] = useState<string>("");
const isValid = useMemo(() => {
let validBundleFor = true;
let validLogFileCount = true;
let validUploadUrl = true;
const logFileCountNumber = Number(logFileCount);
const bundleForTimeNumber = Number(bundleForTime);
@@ -50,8 +63,18 @@ export function CreateDebugJobModalContent({ peerID, onSuccess }: Props) {
validLogFileCount = logFileCountNumber >= 1 && logFileCountNumber <= 1000;
return validLogFileCount && validBundleFor;
}, [bundleForTime, logFileCount]);
const trimmedUploadUrl = uploadUrl.trim();
if (trimmedUploadUrl) {
try {
const parsed = new URL(trimmedUploadUrl);
validUploadUrl = parsed.protocol === "https:" && parsed.host !== "";
} catch {
validUploadUrl = false;
}
}
return validLogFileCount && validBundleFor && validUploadUrl;
}, [bundleForTime, logFileCount, uploadUrl]);
const createDebugJob = async () => {
notify({
@@ -64,11 +87,13 @@ export function CreateDebugJobModalContent({ peerID, onSuccess }: Props) {
type: "bundle",
parameters: {
anonymize,
anonymize_level: anonymize ? anonymizeLevel : undefined,
bundle_for: bundleForTimeEnabled,
bundle_for_time: bundleForTimeEnabled
? Number(bundleForTime)
: undefined,
log_file_count: logFileCount ? Number(logFileCount) : 10,
upload_url: uploadUrl.trim() ? uploadUrl.trim() : undefined,
},
},
})
@@ -172,6 +197,55 @@ export function CreateDebugJobModalContent({ peerID, onSuccess }: Props) {
}
helpText="Remove sensitive information (IP addresses, domains etc.) before creating the debug bundle."
/>
{/* Anonymization Level */}
{anonymize && (
<div className="flex justify-between gap-6">
<div className={"max-w-[300px]"}>
<Label>Anonymization Level</Label>
<HelpText>
Default keeps internal (private) IP ranges readable; Strict also
anonymizes private, CGNAT and link-local addresses.
</HelpText>
</div>
<Select
value={anonymizeLevel}
onValueChange={(v) => setAnonymizeLevel(v as "default" | "strict")}
>
<SelectTrigger className="w-[220px]">
<div className="flex items-center gap-3">
<Shield size={15} className="text-nb-gray-300 shrink-0" />
<SelectValue placeholder="Select level..." />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="strict">Strict</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Upload URL */}
<div className="flex justify-between gap-6">
<div className={"max-w-[300px]"}>
<Label>Upload URL (optional)</Label>
<HelpText>
Service the peer requests an upload URL from. Leave empty to use
the default upload server. Must be an https URL.
</HelpText>
</div>
<Input
type="text"
placeholder={"https://upload.debug.netbird.io"}
value={uploadUrl}
onChange={(e) => setUploadUrl(e.target.value)}
maxWidthClass="w-[220px]"
customPrefix={<UploadCloud size={16} className="text-nb-gray-300" />}
/>
</div>
</div>
<ModalFooter className="items-center">
@@ -0,0 +1,212 @@
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import InlineLink from "@components/InlineLink";
import { Label } from "@components/Label";
import { ModalClose, ModalFooter } from "@components/modal/Modal";
import Paragraph from "@components/Paragraph";
import { Textarea } from "@components/Textarea";
import { uniqueId } from "lodash";
import {
ExternalLinkIcon,
FileKey2Icon,
MinusCircleIcon,
PlusCircle,
} from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
import { CertificateCheck } from "@/interfaces/PostureCheck";
import {
isValidPEMCertificate,
useCertificateFingerprints,
} from "@/modules/posture-checks/helper/CertificateHelper";
import { PostureCheckCard } from "@/modules/posture-checks/ui/PostureCheckCard";
type Props = {
value?: CertificateCheck;
onChange: (value: CertificateCheck | undefined) => void;
disabled?: boolean;
};
export const PostureCheckCertificate = ({
value,
onChange,
disabled,
}: Props) => {
const [open, setOpen] = useState(false);
return (
<PostureCheckCard
open={open}
setOpen={setOpen}
key={open ? 1 : 0}
active={value?.ca_certificates && value.ca_certificates.length > 0}
title={"Certificate"}
description={
"Restrict access to peers holding a certificate issued by your own certificate authority."
}
icon={<FileKey2Icon size={18} />}
iconClass={"bg-gradient-to-tr from-purple-500 to-purple-400"}
modalWidthClass={"max-w-2xl"}
onReset={() => onChange(undefined)}
>
<CheckContent
value={value}
onChange={(v) => {
onChange(v);
setOpen(false);
}}
disabled={disabled}
/>
</PostureCheckCard>
);
};
type CACertificate = {
id: string;
pem: string;
};
const newCertificate = (pem = ""): CACertificate => ({
id: uniqueId("ca-certificate"),
pem,
});
const rowsFor = (pem: string) =>
Math.min(Math.max(pem.split("\n").length + 1, 6), 32);
const CheckContent = ({ value, onChange, disabled }: Props) => {
const [certificates, setCertificates] = useState<CACertificate[]>(
value?.ca_certificates?.length
? value.ca_certificates.map((pem) => newCertificate(pem))
: [newCertificate()],
);
const pems = useMemo(() => certificates.map((c) => c.pem), [certificates]);
const fingerprints = useCertificateFingerprints(pems);
const errors = useMemo(
() =>
certificates.map((c) =>
c.pem.trim() !== "" && !isValidPEMCertificate(c.pem)
? "Please paste a valid PEM encoded certificate"
: "",
),
[certificates],
);
const hasErrorsOrIsEmpty =
certificates.length === 0 ||
certificates.some((c) => c.pem.trim() === "") ||
errors.some((e) => e !== "");
const updateCertificate = (id: string, pem: string) => {
setCertificates(certificates.map((c) => (c.id === id ? { ...c, pem } : c)));
};
const removeCertificate = (id: string) => {
setCertificates(certificates.filter((c) => c.id !== id));
};
return (
<>
<div className={"flex flex-col px-8 gap-2 pb-6"}>
<div className={"flex justify-between items-start gap-10 mt-2"}>
<div>
<Label>CA Certificates</Label>
<HelpText className={""}>
Paste the PEM encoded public certificate of the root or
intermediate CA that issues your device certificates. Peers will
only be allowed to connect if they hold a certificate issued by
one of these CAs and prove possession of its private key.
</HelpText>
</div>
</div>
{certificates.length > 0 && (
<div className={"mb-2 flex flex-col gap-4 w-full"}>
{certificates.map((c, index) => {
return (
<div key={c.id} className={"flex gap-2 items-start min-w-0"}>
<div className={"w-full flex flex-col gap-1.5 min-w-0"}>
<Textarea
value={c.pem}
rows={rowsFor(c.pem)}
placeholder={
"-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----"
}
error={errors[index]}
className={
"w-full font-mono text-xs leading-5 overflow-auto"
}
onChange={(e) => updateCertificate(c.id, e.target.value)}
disabled={disabled}
/>
{fingerprints[index] && (
<span
className={
"text-xs text-nb-gray-400 font-mono break-all"
}
>
SHA-256 {fingerprints[index]}
</span>
)}
</div>
<Button
className={"h-[42px]"}
variant={"default-outline"}
onClick={() => removeCertificate(c.id)}
disabled={disabled}
>
<MinusCircleIcon size={15} />
</Button>
</div>
);
})}
</div>
)}
<Button
variant={"dotted"}
size={"sm"}
onClick={() => setCertificates([...certificates, newCertificate()])}
className={"mt-1"}
disabled={disabled}
>
<PlusCircle size={16} />
Add CA Certificate
</Button>
</div>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink
href={
"https://docs.netbird.io/how-to/manage-posture-checks#certificate-check"
}
target={"_blank"}
>
Certificate Check
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
disabled={hasErrorsOrIsEmpty || disabled}
onClick={() =>
onChange({
ca_certificates: certificates.map((c) => c.pem.trim()),
})
}
>
Save
</Button>
</div>
</ModalFooter>
</>
);
};
Loaded 20 of 26 files, more files were not shown because too many files have changed in this diff. Show more