Compare commits

...
Author SHA1 Message Date
riccardom b786c3395a Drop the self-hosted caveat from the Debug Bundle Upload help text
The client no longer keeps the bundle on the peer when a self-hosted deployment
publishes no destination: netbirdio/netbird now falls back to the upload service
NetBird runs for every deployment, so the field's help text described behaviour
that is not there.
2026-09-11 12:44:57 +02:00
riccardom 3c009ddc0d Add the debug-bundle upload destination to the Clients settings
A debug bundle carries peer logs, routes, DNS and firewall state, and until now
every client path that uploads one without a human picking a destination sent it
to the service NetBird runs, with no way for a self-hosted operator to point it
elsewhere. netbirdio/netbird publishes the destination to peers from the account
settings; this exposes that setting.

Empty keeps the current behaviour for cloud accounts and, for a self-hosted
deployment, makes the peers keep the bundle locally instead. The URL is
validated as https-with-host on the client too, matching what the API accepts:
the peer fetches an upload URL from this endpoint and then PUTs the bundle to
whatever comes back, so a plaintext hop would expose both.
2026-09-09 09:38:55 +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
10 changed files with 795 additions and 142 deletions

No files matched your search

@@ -0,0 +1,151 @@
/**
* 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 });
const upstreamURL = "https://agentgateway.e2e.example";
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();
}
});
});
+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
+1
View File
@@ -35,6 +35,7 @@ export interface Account {
local_mfa_enabled?: boolean;
ipv6_enabled_groups?: string[];
network_range_v6?: string;
debug_bundle_upload_url?: string;
agent_network_only?: boolean;
dashboard_features?: AccountDashboardFeatures;
};
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 [];
}
@@ -18,6 +18,7 @@ import { useApiCall } from "@utils/api";
import { cn, validator } from "@utils/helpers";
import {
AlertTriangle,
BugIcon,
ClockFadingIcon,
ExternalLinkIcon,
MonitorSmartphoneIcon,
@@ -96,6 +97,10 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
account.settings?.auto_update_always ?? false,
);
const [debugBundleUploadUrl, setDebugBundleUploadUrl] = useState(
account.settings?.debug_bundle_upload_url ?? "",
);
const [peerExposeEnabled, setPeerExposeEnabled] = useState<boolean>(
account?.settings?.peer_expose_enabled ?? false,
);
@@ -112,6 +117,7 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
autoUpdateMethod,
autoUpdateCustomVersion,
autoUpdateAlways,
debugBundleUploadUrl,
peerExposeEnabled,
peerExposeGroupNames,
]);
@@ -132,6 +138,21 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
return "";
}, [autoUpdateCustomVersion]);
// Mirrors the rule the API enforces. https only: the client fetches an upload
// URL from this endpoint and then PUTs the bundle to whatever comes back, so a
// plaintext hop is a place to intercept both.
const debugBundleUploadUrlError = useMemo(() => {
if (debugBundleUploadUrl === "") return "";
try {
const parsed = new URL(debugBundleUploadUrl);
if (parsed.protocol !== "https:") return "The URL must use https";
if (!parsed.hostname) return "The URL must have a host";
return "";
} catch {
return "Please enter a valid URL, e.g., https://upload.example.com/upload-url";
}
}, [debugBundleUploadUrl]);
const canSaveCustomVersion =
autoUpdateCustomVersion !== "" &&
autoUpdateMethod === "custom" &&
@@ -142,6 +163,7 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
!hasChanges ||
!permission.settings.update ||
(autoUpdateMethod === "custom" && !canSaveCustomVersion) ||
debugBundleUploadUrlError !== "" ||
(peerExposeEnabled && peerExposeGroups.length === 0)
);
}, [
@@ -149,6 +171,7 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
permission.settings.update,
autoUpdateMethod,
canSaveCustomVersion,
debugBundleUploadUrlError,
peerExposeEnabled,
peerExposeGroups,
]);
@@ -169,6 +192,7 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
...account.settings,
auto_update_version: autoUpdateCustomVersion || autoUpdateMethod,
auto_update_always: autoUpdateAlways,
debug_bundle_upload_url: debugBundleUploadUrl,
peer_expose_enabled: peerExposeEnabled,
peer_expose_groups: peerExposeGroupIds,
},
@@ -179,6 +203,7 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
autoUpdateMethod,
autoUpdateCustomVersion,
autoUpdateAlways,
debugBundleUploadUrl,
peerExposeEnabled,
peerExposeGroupNames,
]);
@@ -398,6 +423,41 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
</div>
</div>
<div>
<Label>
<BugIcon size={15} />
Debug Bundle Upload
</Label>
<HelpText>
Where clients send debug bundles. A bundle carries peer logs,
routes, DNS and firewall state, so this decides whose
infrastructure that data lands on. Leave it empty to use the
service NetBird runs.{" "}
<InlineLink
href={"https://docs.netbird.io/manage/peers/remote-jobs"}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</HelpText>
<div className={"mt-2"}>
<Input
value={debugBundleUploadUrl}
customPrefix={"URL"}
placeholder={"e.g., https://upload.example.com/upload-url"}
error={debugBundleUploadUrlError}
errorTooltip={true}
disabled={!permission.settings.update}
data-cy={"debug-bundle-upload-url"}
onChange={(v) => {
setDebugBundleUploadUrl(v.target.value);
}}
/>
</div>
</div>
<div>
<Label>
<ClockFadingIcon size={15} />