mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca7ae3f924 |
No files matched your search
@@ -1,151 +0,0 @@
|
||||
/**
|
||||
* 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,196 @@
|
||||
/**
|
||||
* Agent Network bootstrap-cluster spec.
|
||||
*
|
||||
* The account's agent network endpoint is assigned once, from the proxy
|
||||
* cluster the connect-provider wizard pins it to, and it is immutable after
|
||||
* that. Not every live cluster can host it: the endpoint is a private service,
|
||||
* reachable only from connected peers and authorised by their tunnel identity,
|
||||
* which only a cluster with a connected embedded proxy (`netbird proxy`) can
|
||||
* serve. Management reports that per cluster as supports_private and refuses a
|
||||
* bootstrap onto a cluster without it.
|
||||
*
|
||||
* This spec pins the wizard's half of that contract: it picks a
|
||||
* private-capable cluster even when a centralised one is listed first, and it
|
||||
* blocks setup — with a message naming what is missing — when no listed
|
||||
* cluster qualifies. Both the settings and domains routes are mocked, so the
|
||||
* assertions do not depend on which clusters the test backend happens to have
|
||||
* connected, and no provider is created against it.
|
||||
*/
|
||||
import { type Browser, expect, type Page, test } from "@playwright/test";
|
||||
import { loginToApp } from "../helpers/auth";
|
||||
|
||||
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
|
||||
|
||||
// Listed first, and unusable: a centralised proxy cluster. Picking it is the
|
||||
// bug this spec guards against.
|
||||
const CENTRAL_CLUSTER = "central.proxy.netbird.io";
|
||||
// Listed second, and the only valid pick.
|
||||
const EMBEDDED_CLUSTER = "embedded.proxy.netbird.io";
|
||||
|
||||
const UNBOOTSTRAPPED_DEFAULTS = {
|
||||
endpoint: "",
|
||||
proxy_address: "",
|
||||
dedicated: false,
|
||||
enable_log_collection: true,
|
||||
enable_prompt_collection: false,
|
||||
redact_pii: false,
|
||||
access_log_retention_days: 30,
|
||||
};
|
||||
|
||||
const BOOTSTRAPPED_SETTINGS = {
|
||||
...UNBOOTSTRAPPED_DEFAULTS,
|
||||
endpoint: `violet.${EMBEDDED_CLUSTER}`,
|
||||
proxy_address: EMBEDDED_CLUSTER,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
/** A free (cluster-backed) domain as /reverse-proxies/domains reports it. */
|
||||
function freeDomain(name: string, supportsPrivate: boolean) {
|
||||
return {
|
||||
id: name,
|
||||
domain: name,
|
||||
validated: true,
|
||||
type: "free",
|
||||
supports_private: supportsPrivate,
|
||||
supports_custom_ports: true,
|
||||
require_subdomain: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function json(route: import("@playwright/test").Route, body: unknown) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the providers page for an account that has not been bootstrapped,
|
||||
* with the given clusters on the domains endpoint. Returns the page plus a
|
||||
* promise-friendly getter for the bootstrap POST body, which is captured and
|
||||
* answered locally so the real backend is never mutated.
|
||||
*/
|
||||
async function openWizard(
|
||||
browser: Browser,
|
||||
domains: ReturnType<typeof freeDomain>[],
|
||||
): Promise<{ page: Page; close: () => Promise<void> }> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
try {
|
||||
await context.addInitScript(
|
||||
([key, value]) => {
|
||||
try {
|
||||
window.localStorage.setItem(key as string, value as string);
|
||||
} catch (e) {}
|
||||
},
|
||||
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
|
||||
);
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.route("**/api/agent-network/settings", (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") return json(route, UNBOOTSTRAPPED_DEFAULTS);
|
||||
if (method === "POST") return json(route, BOOTSTRAPPED_SETTINGS);
|
||||
return route.continue();
|
||||
});
|
||||
await page.route("**/api/reverse-proxies/domains", (route) =>
|
||||
route.request().method() === "GET"
|
||||
? json(route, domains)
|
||||
: route.continue(),
|
||||
);
|
||||
// Keep the create hermetic: the wizard only reaches this once the
|
||||
// bootstrap it does first has succeeded.
|
||||
await page.route("**/api/agent-network/providers", (route) =>
|
||||
route.request().method() === "POST"
|
||||
? json(route, { id: "prov-e2e", name: "e2e-provider" })
|
||||
: route.continue(),
|
||||
);
|
||||
|
||||
await loginToApp(page, "owner");
|
||||
await page.goto("/agent-network/providers");
|
||||
await page.keyboard.press("Escape");
|
||||
return { page, close: () => context.close() };
|
||||
} catch (e) {
|
||||
await context.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fills the provider tab with the minimum a create needs. */
|
||||
async function fillProviderTab(page: Page) {
|
||||
await page.getByPlaceholder("https://api.openai.com").fill("https://api.openai.com");
|
||||
await page.getByPlaceholder("sk-...").first().fill("sk-e2e-bootstrap-key");
|
||||
}
|
||||
|
||||
test.describe("Agent Network bootstrap cluster @agent-network", () => {
|
||||
test("bootstraps onto a private-capable cluster, skipping a centralised one listed first", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWizard(browser, [
|
||||
freeDomain(CENTRAL_CLUSTER, false),
|
||||
freeDomain(EMBEDDED_CLUSTER, true),
|
||||
]);
|
||||
try {
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
|
||||
// The unusable cluster is listed, but it is not a blocker: a usable one
|
||||
// exists, so the wizard proceeds without the warning.
|
||||
await expect(
|
||||
page.getByTestId("agent-network-no-cluster-callout"),
|
||||
).toHaveCount(0);
|
||||
|
||||
await fillProviderTab(page);
|
||||
|
||||
const bootstrap = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/agent-network/settings") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "Continue" }).click({ force: true });
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.last()
|
||||
.click({ force: true });
|
||||
|
||||
const payload = (await bootstrap).request().postDataJSON();
|
||||
expect(payload.proxy_address).toBe(EMBEDDED_CLUSTER);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("blocks setup when no listed cluster has an embedded proxy", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openWizard(browser, [
|
||||
freeDomain(CENTRAL_CLUSTER, false),
|
||||
]);
|
||||
try {
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
|
||||
const callout = page.getByTestId("agent-network-no-cluster-callout");
|
||||
await expect(callout).toBeVisible({ timeout: 10_000 });
|
||||
await expect(callout).toContainText("embedded proxy");
|
||||
|
||||
// With nothing to pin to there is no bootstrap to make, so the wizard
|
||||
// cannot advance — rather than sending a request management refuses.
|
||||
await fillProviderTab(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Continue" }),
|
||||
).toBeDisabled();
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,195 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,9 @@ type Props = {
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
/** Lets callers mark a callout so tests can assert on the state it
|
||||
* announces rather than on its copy. */
|
||||
"data-testid"?: string;
|
||||
} & CalloutVariants;
|
||||
|
||||
export const calloutVariants = cva(
|
||||
@@ -31,9 +34,13 @@ export const Callout = ({
|
||||
icon = <InfoIcon size={14} className={"shrink-0 relative top-[3px]"} />,
|
||||
className,
|
||||
variant = "default",
|
||||
"data-testid": dataTestId,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className={cn(calloutVariants({ variant }), className)}>
|
||||
<div
|
||||
className={cn(calloutVariants({ variant }), className)}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
{icon}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -180,22 +180,6 @@ 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
|
||||
|
||||
@@ -35,7 +35,6 @@ 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,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { notify } from "@components/Notification";
|
||||
import { IconCircleX } from "@tabler/icons-react";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import React, {
|
||||
createContext,
|
||||
@@ -218,18 +217,6 @@ 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
|
||||
@@ -510,13 +497,7 @@ type AIProvidersContextValue = {
|
||||
closeWizard: () => void;
|
||||
isWizardOpen: boolean;
|
||||
addProvider: (input: ProviderConnectInput) => Promise<AIProvider | undefined>;
|
||||
// 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>;
|
||||
updateProvider: (id: string, updates: ProviderUpdateInput) => Promise<void>;
|
||||
toggleProvider: (id: string) => Promise<void>;
|
||||
deleteProvider: (id: string) => Promise<void>;
|
||||
addPolicy: (
|
||||
@@ -628,11 +609,6 @@ 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<
|
||||
@@ -712,24 +688,21 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
|
||||
const addProvider = useCallback(
|
||||
async (input: ProviderConnectInput) => {
|
||||
let created: APIProvider;
|
||||
try {
|
||||
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.
|
||||
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),
|
||||
});
|
||||
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],
|
||||
);
|
||||
@@ -737,16 +710,7 @@ 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) {
|
||||
// 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;
|
||||
}
|
||||
if (!existing) return;
|
||||
const merged: APIProviderRequest = {
|
||||
provider_id: updates.providerId ?? existing.provider_id,
|
||||
name: updates.name ?? existing.name,
|
||||
@@ -773,18 +737,17 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
};
|
||||
try {
|
||||
await providersApi.put(merged, `/${id}`);
|
||||
} catch {
|
||||
// Reported already by the shared request-failed toast.
|
||||
return false;
|
||||
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),
|
||||
});
|
||||
}
|
||||
// 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],
|
||||
);
|
||||
@@ -808,7 +771,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
description: "Endpoint will be torn down on next mapping update.",
|
||||
});
|
||||
} catch (err) {
|
||||
notifyFailure({
|
||||
notify({
|
||||
title: "Failed to remove provider",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -828,7 +791,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
});
|
||||
return policyFromAPI(created);
|
||||
} catch (err) {
|
||||
notifyFailure({
|
||||
notify({
|
||||
title: "Failed to create policy",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -864,7 +827,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
description: "Settings saved.",
|
||||
});
|
||||
} catch (err) {
|
||||
notifyFailure({
|
||||
notify({
|
||||
title: "Failed to update policy",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -892,7 +855,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
description: "Policy deleted.",
|
||||
});
|
||||
} catch (err) {
|
||||
notifyFailure({
|
||||
notify({
|
||||
title: "Failed to remove policy",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -912,7 +875,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
});
|
||||
return guardrailFromAPI(created);
|
||||
} catch (err) {
|
||||
notifyFailure({
|
||||
notify({
|
||||
title: "Failed to create guardrail",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -941,7 +904,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
description: "Settings saved.",
|
||||
});
|
||||
} catch (err) {
|
||||
notifyFailure({
|
||||
notify({
|
||||
title: "Failed to update guardrail",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -961,7 +924,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
"Existing policies still reference this guardrail until you detach it.",
|
||||
});
|
||||
} catch (err) {
|
||||
notifyFailure({
|
||||
notify({
|
||||
title: "Failed to remove guardrail",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -981,7 +944,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
});
|
||||
return budgetRuleFromAPI(created);
|
||||
} catch (err) {
|
||||
notifyFailure({
|
||||
notify({
|
||||
title: "Failed to create global limit",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -1015,7 +978,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
description: "Settings saved.",
|
||||
});
|
||||
} catch (err) {
|
||||
notifyFailure({
|
||||
notify({
|
||||
title: "Failed to update global limit",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -1043,7 +1006,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
description: "Global limit deleted.",
|
||||
});
|
||||
} catch (err) {
|
||||
notifyFailure({
|
||||
notify({
|
||||
title: "Failed to remove global limit",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -1062,7 +1025,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
} catch (err) {
|
||||
const code = (err as { code?: number })?.code;
|
||||
if (code !== 409) {
|
||||
notifyFailure({
|
||||
notify({
|
||||
title: "Failed to set up the agent network endpoint",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -1084,7 +1047,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) {
|
||||
notifyFailure({
|
||||
notify({
|
||||
title: "Failed to update account controls",
|
||||
description: "Agent Network has not been set up yet.",
|
||||
});
|
||||
@@ -1099,7 +1062,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
notifyFailure({
|
||||
notify({
|
||||
title: "Failed to update account controls",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* 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.
|
||||
|
||||
@@ -13,7 +14,6 @@ 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 { 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";
|
||||
import AgentProviderActionCell from "@/modules/agent-network/table/AgentProviderActionCell";
|
||||
|
||||
function NameCell({ provider }: { provider: AIProvider }) {
|
||||
const { getById } = useProviderCatalog();
|
||||
@@ -70,20 +70,10 @@ function NameCell({ provider }: { provider: AIProvider }) {
|
||||
|
||||
function ModelsCell({ provider }: { provider: AIProvider }) {
|
||||
if (provider.models.length === 0) {
|
||||
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-400"}>All Models</span>;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={"text-xs text-nb-gray-300"}
|
||||
data-testid={`provider-models-${provider.name}`}
|
||||
>
|
||||
<span className={"text-xs text-nb-gray-300"}>
|
||||
{provider.models.length} Models
|
||||
</span>
|
||||
);
|
||||
@@ -222,11 +212,7 @@ 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}
|
||||
data-testid={"connect-agent-network-provider"}
|
||||
>
|
||||
<Button variant={"primary"} onClick={openWizard}>
|
||||
<PlusCircle size={16} />
|
||||
Connect Provider
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { type ErrorResponse, useApiCall } from "@utils/api";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
// DiscoveredModel is one model the vendor says this credential can reach.
|
||||
@@ -27,8 +27,6 @@ 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
|
||||
@@ -98,13 +96,7 @@ 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.
|
||||
//
|
||||
// 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;
|
||||
const status = (e as { status?: number })?.status;
|
||||
setState({
|
||||
models: [],
|
||||
isLoading: false,
|
||||
@@ -112,7 +104,8 @@ export function useDiscoveredModels() {
|
||||
error:
|
||||
status === 422
|
||||
? undefined
|
||||
: failure?.message ?? "Could not reach the provider",
|
||||
: (e as { message?: string })?.message ??
|
||||
"Could not reach the provider",
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { useApiCall } from "@utils/api";
|
||||
import { cn, validator } from "@utils/helpers";
|
||||
import {
|
||||
AlertTriangle,
|
||||
BugIcon,
|
||||
ClockFadingIcon,
|
||||
ExternalLinkIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
@@ -97,10 +96,6 @@ 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,
|
||||
);
|
||||
@@ -117,7 +112,6 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
|
||||
autoUpdateMethod,
|
||||
autoUpdateCustomVersion,
|
||||
autoUpdateAlways,
|
||||
debugBundleUploadUrl,
|
||||
peerExposeEnabled,
|
||||
peerExposeGroupNames,
|
||||
]);
|
||||
@@ -138,21 +132,6 @@ 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" &&
|
||||
@@ -163,7 +142,6 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
|
||||
!hasChanges ||
|
||||
!permission.settings.update ||
|
||||
(autoUpdateMethod === "custom" && !canSaveCustomVersion) ||
|
||||
debugBundleUploadUrlError !== "" ||
|
||||
(peerExposeEnabled && peerExposeGroups.length === 0)
|
||||
);
|
||||
}, [
|
||||
@@ -171,7 +149,6 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
|
||||
permission.settings.update,
|
||||
autoUpdateMethod,
|
||||
canSaveCustomVersion,
|
||||
debugBundleUploadUrlError,
|
||||
peerExposeEnabled,
|
||||
peerExposeGroups,
|
||||
]);
|
||||
@@ -192,7 +169,6 @@ 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,
|
||||
},
|
||||
@@ -203,7 +179,6 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
|
||||
autoUpdateMethod,
|
||||
autoUpdateCustomVersion,
|
||||
autoUpdateAlways,
|
||||
debugBundleUploadUrl,
|
||||
peerExposeEnabled,
|
||||
peerExposeGroupNames,
|
||||
]);
|
||||
@@ -423,41 +398,6 @@ 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} />
|
||||
|
||||
Reference in new issue
Block a user