mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb05f2c172 | ||
|
|
6cf828ce8a | ||
|
|
83cb07a44c | ||
|
|
bbfa2d3a79 | ||
|
|
a92816b9b8 | ||
|
|
0caf754897 | ||
|
|
49582fff74 | ||
|
|
af7745a03b | ||
|
|
19d82cc2ab |
No files matched your search
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals","prettier"],
|
||||
"plugins": ["simple-import-sort"],
|
||||
"rules": {
|
||||
"simple-import-sort/imports": [
|
||||
"warn",
|
||||
{
|
||||
"groups": [["^\\u0000", "^@?\\w", "^[^.]", "^\\."]]
|
||||
}
|
||||
],
|
||||
"simple-import-sort/exports": "warn"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.14
|
||||
FROM alpine:3.24
|
||||
|
||||
RUN apk add --no-cache bash curl less ca-certificates git tzdata zip gettext \
|
||||
nginx curl supervisor certbot-nginx && \
|
||||
|
||||
@@ -483,3 +483,28 @@ export async function deleteAgentNetworkProvidersByPrefix(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the management build under test bootstraps the Agent Network
|
||||
* settings row through the explicit POST /agent-network/settings.
|
||||
*
|
||||
* Builds that predate it seed the row as a side effect of the first provider
|
||||
* create and describe the endpoint identity as cluster + subdomain; builds
|
||||
* that have it expose proxy_address instead. The settings GET carries the
|
||||
* field either way — including before the account is bootstrapped, where it
|
||||
* answers with the defaults and an empty proxy_address — so the shape of that
|
||||
* response is a side-effect-free probe for the endpoint's existence.
|
||||
*/
|
||||
export async function supportsAgentNetworkSettingsBootstrap(
|
||||
page: Page,
|
||||
): Promise<boolean> {
|
||||
const { token, origin } = await getApiContext(page);
|
||||
const resp = await page.request.get(`${origin}/api/agent-network/settings`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!resp.ok()) return false;
|
||||
const settings = await resp.json().catch(() => null);
|
||||
return (
|
||||
!!settings && typeof settings === "object" && "proxy_address" in settings
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,10 @@
|
||||
* The kimi_api catalog entry ships with newer management builds. When the
|
||||
* backend under test predates it, the whole suite skips instead of failing —
|
||||
* same trade-off as the provider matrix in netbird's agent-network e2e
|
||||
* harness, which skips per-provider on missing credentials.
|
||||
* harness, which skips per-provider on missing credentials. The same applies
|
||||
* to the explicit settings bootstrap: connecting the first provider of an
|
||||
* account POSTs /agent-network/settings first, so on a build without that
|
||||
* endpoint the wizard cannot get as far as creating a provider.
|
||||
*
|
||||
* The Agent Network menu is deployment-gated; the test build honors the
|
||||
* localStorage override (see testAgentNetworkOverride in utils/netbird.ts),
|
||||
@@ -22,6 +25,7 @@ import { generateRandomName } from "../helpers/utils";
|
||||
import {
|
||||
deleteAgentNetworkProvidersByPrefix,
|
||||
listAgentNetworkCatalog,
|
||||
supportsAgentNetworkSettingsBootstrap,
|
||||
} from "../helpers/api";
|
||||
|
||||
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
|
||||
@@ -62,6 +66,11 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
|
||||
!catalog.some((c) => c.id === KIMI_CATALOG_ID),
|
||||
`management catalog has no ${KIMI_CATALOG_ID} entry`,
|
||||
);
|
||||
test.skip(
|
||||
!(await supportsAgentNetworkSettingsBootstrap(page)),
|
||||
"management build has no POST /agent-network/settings, so the " +
|
||||
"wizard cannot bootstrap the account before the first create",
|
||||
);
|
||||
|
||||
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
|
||||
|
||||
@@ -110,11 +119,40 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
// On the account's first provider the wizard bootstraps the settings row
|
||||
// first and only creates the provider once that succeeds. A rejected
|
||||
// bootstrap therefore means no provider POST ever happens, which on its
|
||||
// own surfaces only as the whole test timing out — race it so the real
|
||||
// status is reported instead. 409 is not a rejection: it means a
|
||||
// concurrent bootstrap won and the row exists, which the wizard treats as
|
||||
// success and follows with the provider create, so matching it here would
|
||||
// fail a run that is about to succeed.
|
||||
const bootstrapRejected: Promise<never> = page
|
||||
.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/agent-network/settings") &&
|
||||
resp.request().method() === "POST" &&
|
||||
resp.status() !== 409 &&
|
||||
!resp.ok(),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.then(
|
||||
(resp) => {
|
||||
throw new Error(
|
||||
`Agent Network settings bootstrap POST returned ${resp.status()}; ` +
|
||||
"the provider was never created",
|
||||
);
|
||||
},
|
||||
// Nothing matched: the bootstrap succeeded or wasn't needed. Never
|
||||
// settle, leaving the race to the provider create.
|
||||
() => new Promise<never>(() => {}),
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "Connect Provider" })
|
||||
.last()
|
||||
.click({ force: true });
|
||||
expect([200, 201]).toContain((await createResponse).status());
|
||||
const created = await Promise.race([createResponse, bootstrapRejected]);
|
||||
expect([200, 201]).toContain(created.status());
|
||||
|
||||
// Row lands in the providers table.
|
||||
await expect(page.getByText(providerName).first()).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Agent Network settings wire-shape spec.
|
||||
*
|
||||
* The management API signals "account not bootstrapped" differently by
|
||||
* generation: current servers answer GET /agent-network/settings with the
|
||||
* defaults object carrying an empty endpoint/proxy_address, older ones with
|
||||
* 200 + a JSON null body, and the oldest with a 404.
|
||||
* useAgentNetworkSettings normalizes all three to the same null-settings
|
||||
* signal, and this spec pins that: the providers page must render the
|
||||
* connect-first endpoint placeholder for every unbootstrapped shape, and the
|
||||
* endpoint badge once the account is bootstrapped. The settings route is
|
||||
* mocked per test, so the assertions do not depend on the backend build or
|
||||
* on account state left behind by other suites.
|
||||
*/
|
||||
import { test, expect, type Browser, type Page } from "@playwright/test";
|
||||
import { loginToApp } from "../helpers/auth";
|
||||
|
||||
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
|
||||
|
||||
const EMPTY_STATE_TEXT =
|
||||
"Connect your first provider to set up your agent network endpoint.";
|
||||
|
||||
const BOOTSTRAPPED_ENDPOINT = "violet.eu.proxy.netbird.io";
|
||||
|
||||
// The defaults object current servers return for an account that has not been
|
||||
// bootstrapped yet: the mutable settings carry their default values, the
|
||||
// endpoint/proxy_address identity is empty, and there are no timestamps because
|
||||
// no row has been persisted.
|
||||
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 = {
|
||||
endpoint: BOOTSTRAPPED_ENDPOINT,
|
||||
proxy_address: "eu.proxy.netbird.io",
|
||||
dedicated: false,
|
||||
enable_log_collection: true,
|
||||
enable_prompt_collection: false,
|
||||
redact_pii: false,
|
||||
access_log_retention_days: 30,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
async function mockSettingsResponse(
|
||||
page: Page,
|
||||
response: { status: number; body: string },
|
||||
) {
|
||||
await page.route("**/api/agent-network/settings", (route) => {
|
||||
if (route.request().method() !== "GET") return route.continue();
|
||||
return route.fulfill({
|
||||
status: response.status,
|
||||
contentType: "application/json",
|
||||
body: response.body,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openProvidersPage(
|
||||
browser: Browser,
|
||||
settingsResponse: { status: number; body: string },
|
||||
): Promise<{ page: Page; close: () => Promise<void> }> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
// Close the context on any setup failure — the caller only receives the
|
||||
// close callback once setup succeeds.
|
||||
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 mockSettingsResponse(page, settingsResponse);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const UNBOOTSTRAPPED_SHAPES: {
|
||||
name: string;
|
||||
response: { status: number; body: string };
|
||||
}[] = [
|
||||
{
|
||||
name: "defaults object with empty endpoint (current servers)",
|
||||
response: { status: 200, body: JSON.stringify(UNBOOTSTRAPPED_DEFAULTS) },
|
||||
},
|
||||
{
|
||||
name: "200 with JSON null body (older servers)",
|
||||
response: { status: 200, body: "null" },
|
||||
},
|
||||
{
|
||||
name: "404 (oldest servers)",
|
||||
response: {
|
||||
status: 404,
|
||||
body: JSON.stringify({ message: "settings not found", code: 404 }),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("Agent Network settings wire shapes @agent-network", () => {
|
||||
for (const shape of UNBOOTSTRAPPED_SHAPES) {
|
||||
test(`unbootstrapped account renders the empty state: ${shape.name}`, async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openProvidersPage(browser, shape.response);
|
||||
try {
|
||||
await expect(page.getByText(EMPTY_STATE_TEXT)).toBeVisible();
|
||||
await expect(page.getByText(BOOTSTRAPPED_ENDPOINT)).toHaveCount(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("bootstrapped account renders the endpoint instead of the empty state", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openProvidersPage(browser, {
|
||||
status: 200,
|
||||
body: JSON.stringify(BOOTSTRAPPED_SETTINGS),
|
||||
});
|
||||
try {
|
||||
await expect(page.getByText(BOOTSTRAPPED_ENDPOINT)).toBeVisible();
|
||||
await expect(page.getByText(EMPTY_STATE_TEXT)).toHaveCount(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
@@ -20,17 +21,9 @@ test.describe.serial("Team - Service Users @team", () => {
|
||||
});
|
||||
|
||||
test("Should update role and manage access tokens", async ({ dashboardAsOwner: page }) => {
|
||||
await page.locator("tr").getByText(regularUser).click();
|
||||
await openServiceUser(page, regularUser);
|
||||
await changeRoleTo(page, "Admin");
|
||||
// Await the PUT so the role change is persisted before the next serial
|
||||
// test asserts it — clicking save alone returns before the request lands.
|
||||
const saveResponse = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/api/users/") && resp.request().method() === "PUT",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("save-changes").click();
|
||||
await saveResponse;
|
||||
await saveUserChanges(page);
|
||||
|
||||
// Create and delete access token
|
||||
const tokenName = generateRandomName("tkn_");
|
||||
@@ -45,23 +38,18 @@ test.describe.serial("Team - Service Users @team", () => {
|
||||
await tokenRow.getByTestId("access-token-delete").click();
|
||||
await page.getByTestId("confirmation.confirm").click();
|
||||
await expect(tokenRow).not.toBeVisible();
|
||||
|
||||
await page.getByText("Service Users").first().click();
|
||||
});
|
||||
|
||||
test("Should update admin user role and verify all changes persisted", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await page.locator("tr").getByText(adminServiceUser).click();
|
||||
await openServiceUser(page, adminServiceUser);
|
||||
await changeRoleTo(page, "User");
|
||||
const saveResponse = page.waitForResponse(
|
||||
(resp) => resp.url().includes("/api/users/") && resp.request().method() === "PUT",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("save-changes").click();
|
||||
await saveResponse;
|
||||
await saveUserChanges(page);
|
||||
|
||||
await page.getByText("Service Users").first().click();
|
||||
// Go back the way a user would, so this also covers the save refreshing
|
||||
// the cached users list rather than only what the server persisted.
|
||||
await returnToServiceUserList(page);
|
||||
await checkServiceUserRow(page, regularUser, "Admin");
|
||||
await checkServiceUserRow(page, adminServiceUser, "User");
|
||||
|
||||
@@ -72,6 +60,7 @@ test.describe.serial("Team - Service Users @team", () => {
|
||||
});
|
||||
|
||||
test("Should delete service users", async ({ dashboardAsOwner: page }) => {
|
||||
await openServiceUserList(page);
|
||||
for (const name of [regularUser, adminServiceUser]) {
|
||||
const row = page.locator("tr").filter({ hasText: name });
|
||||
// Row actions are now behind a dropdown menu; open it, then delete.
|
||||
@@ -83,11 +72,7 @@ test.describe.serial("Team - Service Users @team", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function createServiceUser(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
role: string,
|
||||
) {
|
||||
async function createServiceUser(page: Page, name: string, role: string) {
|
||||
await page.getByTestId("open-service-user-modal").click();
|
||||
await expect(page.getByTestId("service-user-name")).toBeVisible({ timeout: 5_000 });
|
||||
await page.getByTestId("service-user-name").fill(name);
|
||||
@@ -101,23 +86,86 @@ async function createServiceUser(
|
||||
await expect(page.getByTestId("service-user-name")).not.toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
async function checkServiceUserRow(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
role: string,
|
||||
) {
|
||||
// openServiceUserList lands on the service users table and waits for it to
|
||||
// settle.
|
||||
async function openServiceUserList(page: Page) {
|
||||
await navigateTo(page, "/team/service-users");
|
||||
await expectServiceUserList(page);
|
||||
}
|
||||
|
||||
// returnToServiceUserList goes back to the list from a user's page the way a
|
||||
// user would, via the breadcrumb. Unlike a plain click on the nav entry, it
|
||||
// waits for the navigation to actually land: the click itself resolves while
|
||||
// the client-side navigation is still in flight, so anything that follows can
|
||||
// otherwise be computed against the page being left behind.
|
||||
async function returnToServiceUserList(page: Page) {
|
||||
await page
|
||||
.getByTestId("breadcrumb-item")
|
||||
.filter({ hasText: "Service Users" })
|
||||
.click();
|
||||
await page.waitForURL(/\/team\/service-users/, { timeout: 15_000 });
|
||||
await expectServiceUserList(page);
|
||||
}
|
||||
|
||||
async function expectServiceUserList(page: Page) {
|
||||
await expect(page.getByRole("heading", { name: /Service Users/ })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
// openServiceUser opens one service user's page from the list and only returns
|
||||
// once that page really belongs to `name`. The row click is a client-side
|
||||
// navigation and the table re-renders as the users fetch revalidates, so
|
||||
// clicking a row without confirming where we landed can silently leave us on a
|
||||
// different user — and every later edit then targets that other user.
|
||||
async function openServiceUser(page: Page, name: string) {
|
||||
await openServiceUserList(page);
|
||||
|
||||
const row = page.locator("tr").filter({ hasText: name });
|
||||
await expect(row).toBeVisible({ timeout: 10_000 });
|
||||
await row.click();
|
||||
|
||||
await page.waitForURL(/\/team\/user\?/, { timeout: 15_000 });
|
||||
await expect(page.getByRole("heading", { name, exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function checkServiceUserRow(page: Page, name: string, role: string) {
|
||||
const row = page.locator("tr").filter({ hasText: name });
|
||||
await expect(row).toBeVisible({ timeout: 10_000 });
|
||||
await expect(row.getByText(role, { exact: true }).first()).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function changeRoleTo(
|
||||
page: import("@playwright/test").Page,
|
||||
role: string,
|
||||
) {
|
||||
async function changeRoleTo(page: Page, role: string) {
|
||||
await page.getByTestId("user-role-selector").click();
|
||||
await page
|
||||
.getByTestId("user-role-selector-item")
|
||||
.getByText(role, { exact: true })
|
||||
.click();
|
||||
// Confirm the pick landed. A missed dropdown click leaves the role
|
||||
// unchanged, which disables "Save Changes" and turns the save below into an
|
||||
// unexplained 30s wait for a PUT that is never sent.
|
||||
await expect(
|
||||
page.getByTestId("user-role-selector").getByText(role, { exact: true }),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
// saveUserChanges saves the open user page and awaits the PUT, so the change is
|
||||
// persisted before the next serial test asserts it — clicking save alone
|
||||
// returns before the request lands. The PUT is pinned to the user currently
|
||||
// open and its status checked: a PUT for some other user, or a rejected one,
|
||||
// otherwise leaves the account in a state later assertions blame on the UI.
|
||||
async function saveUserChanges(page: Page) {
|
||||
const userId = new URL(page.url()).searchParams.get("id");
|
||||
expect(userId, "expected a user page with an id parameter").toBeTruthy();
|
||||
|
||||
const saveResponse = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes(`/api/users/${userId}`) &&
|
||||
resp.request().method() === "PUT",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("save-changes").click();
|
||||
expect((await saveResponse).status()).toBe(200);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||
import prettier from "eslint-config-prettier";
|
||||
import simpleImportSort from "eslint-plugin-simple-import-sort";
|
||||
|
||||
const config = [
|
||||
{
|
||||
ignores: [
|
||||
".claude/**",
|
||||
"playwright-report/**",
|
||||
"test-logs/**",
|
||||
"public/**",
|
||||
],
|
||||
},
|
||||
...nextCoreWebVitals,
|
||||
prettier,
|
||||
{
|
||||
files: ["**/*.{js,jsx,mjs,ts,tsx,mts,cts}"],
|
||||
plugins: { "simple-import-sort": simpleImportSort },
|
||||
rules: {
|
||||
"simple-import-sort/imports": [
|
||||
"warn",
|
||||
{
|
||||
groups: [["^\\u0000", "^@?\\w", "^[^.]", "^\\."]],
|
||||
},
|
||||
],
|
||||
"simple-import-sort/exports": "warn",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default config;
|
||||
Generated
+643
-571
File diff suppressed because it is too large.
Load diff
+4
-7
@@ -9,11 +9,10 @@
|
||||
"copy": "copyfiles -f ./node_modules/@axa-fr/react-oidc/dist/OidcServiceWorker.js ./public",
|
||||
"copytrusted": "copyfiles -f ./public/local/OidcTrustedDomains.js ./public",
|
||||
"dev": "next dev -p 3000",
|
||||
"turbo": "next dev -p 3000 --turbo",
|
||||
"build": "next build",
|
||||
"postbuild": "node postbuild.js",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint .",
|
||||
"test:setup": "cd ./e2e/environment && sh create-test-env.sh",
|
||||
"test:clean": "cd ./e2e/environment && sh clean-test-env.sh",
|
||||
"test:dev": "cross-env APP_ENV=test next dev -p 1337",
|
||||
@@ -71,12 +70,10 @@
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"framer-motion": "^12.29.2",
|
||||
"ip-address": "^10.2.0",
|
||||
"ip-cidr": "^3.1.0",
|
||||
"js-cookie": "^3.0.7",
|
||||
"lodash": "4.18.1",
|
||||
"lucide-react": "^0.566.0",
|
||||
"next": "16.1.7",
|
||||
"next-themes": "^0.2.1",
|
||||
"next": "16.3.0",
|
||||
"punycode": "^2.3.1",
|
||||
"react": "^19.2.4",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
@@ -103,11 +100,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^9.5.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/chroma-js": "^3.1.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"eslint-config-next": "^16.3.0",
|
||||
"postcss": "^8",
|
||||
"prettier": "3.0.3",
|
||||
"tailwindcss": "^3.4.17"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 88 KiB |
@@ -17,6 +17,13 @@ function formatVersion(version: string): string {
|
||||
return version;
|
||||
}
|
||||
|
||||
// Self-hosted builds can carry a numeric build suffix (e.g. "0.76.3-31256681241")
|
||||
// that overflows the sidebar. Show the semver only and keep the rest for the
|
||||
// tooltip. Pre-release labels like "-rc.1" are left intact so they stay visible.
|
||||
function formatShortVersion(version: string): string {
|
||||
return formatVersion(version).replace(/^(v?\d+(?:\.\d+)*)-\d+$/, "$1");
|
||||
}
|
||||
|
||||
function compareVersions(current: string, latest: string): boolean {
|
||||
// Returns true if latest is newer than current
|
||||
if (!current || !latest) return false;
|
||||
@@ -98,9 +105,15 @@ const NavigationVersionInfoContent = () => {
|
||||
<div className="flex flex-col gap-1 text-nb-gray-400">
|
||||
<FullTooltip
|
||||
content={
|
||||
<span className="text-xs">
|
||||
Latest: {formatVersion(versionInfo.management_available_version)}
|
||||
</span>
|
||||
<div className="text-xs flex flex-col gap-1">
|
||||
<span>
|
||||
Installed:{" "}
|
||||
{formatVersion(versionInfo.management_current_version)}
|
||||
</span>
|
||||
<span>
|
||||
Latest: {formatVersion(versionInfo.management_available_version)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
side="top"
|
||||
className="w-full"
|
||||
@@ -108,15 +121,18 @@ const NavigationVersionInfoContent = () => {
|
||||
<div className="flex items-center justify-between w-full cursor-default">
|
||||
<span>Management</span>
|
||||
<span className="text-nb-gray-300 font-medium">
|
||||
{formatVersion(versionInfo.management_current_version)}
|
||||
{formatShortVersion(versionInfo.management_current_version)}
|
||||
</span>
|
||||
</div>
|
||||
</FullTooltip>
|
||||
<FullTooltip
|
||||
content={
|
||||
<span className="text-xs">
|
||||
Latest: {formatVersion(versionInfo.dashboard_available_version)}
|
||||
</span>
|
||||
<div className="text-xs flex flex-col gap-1">
|
||||
<span>Installed: {formatVersion(dashboardVersion)}</span>
|
||||
<span>
|
||||
Latest: {formatVersion(versionInfo.dashboard_available_version)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
side="top"
|
||||
className="w-full"
|
||||
@@ -124,7 +140,7 @@ const NavigationVersionInfoContent = () => {
|
||||
<div className="flex items-center justify-between w-full cursor-default">
|
||||
<span>Dashboard</span>
|
||||
<span className="text-nb-gray-300 font-medium">
|
||||
{formatVersion(dashboardVersion)}
|
||||
{formatShortVersion(dashboardVersion)}
|
||||
</span>
|
||||
</div>
|
||||
</FullTooltip>
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/DropdownMenu";
|
||||
import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "@/contexts/ThemeProvider";
|
||||
|
||||
export default function DarkModeToggle() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import "react-loading-skeleton/dist/skeleton.css";
|
||||
import dynamic from "next/dynamic";
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||
import * as React from "react";
|
||||
import { SkeletonTheme } from "react-loading-skeleton";
|
||||
|
||||
const NextThemesProvider = dynamic(
|
||||
() => import("next-themes").then((mod) => mod.ThemeProvider),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export function GlobalThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
storageKey="netbird-theme"
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
{...props}
|
||||
>
|
||||
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
|
||||
{children}
|
||||
</SkeletonTheme>
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import "react-loading-skeleton/dist/skeleton.css";
|
||||
import React, { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { SkeletonTheme } from "react-loading-skeleton";
|
||||
|
||||
export type Theme = "dark" | "light" | "system";
|
||||
export type ResolvedTheme = Exclude<Theme, "system">;
|
||||
|
||||
const STORAGE_KEY = "netbird-theme";
|
||||
const DEFAULT_THEME: ResolvedTheme = "dark";
|
||||
const THEME_CLASSES: ResolvedTheme[] = ["dark", "light"];
|
||||
const SKELETON_COLORS: Record<
|
||||
ResolvedTheme,
|
||||
{ base: string; highlight: string }
|
||||
> = {
|
||||
dark: { base: "#25282d", highlight: "#33373e" },
|
||||
light: { base: "#e4e7e9", highlight: "#f4f6f7" },
|
||||
};
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const ThemeContext = React.createContext(
|
||||
{} as {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
},
|
||||
);
|
||||
|
||||
function isTheme(value: string | null): value is Theme {
|
||||
return value === "dark" || value === "light" || value === "system";
|
||||
}
|
||||
|
||||
function systemTheme(): ResolvedTheme {
|
||||
return window.matchMedia("(prefers-color-scheme: light)").matches
|
||||
? "light"
|
||||
: "dark";
|
||||
}
|
||||
|
||||
function resolveTheme(theme: Theme): ResolvedTheme {
|
||||
return theme === "system" ? systemTheme() : theme;
|
||||
}
|
||||
|
||||
// storedTheme reads the persisted preference. Access to localStorage can throw
|
||||
// when the browser blocks storage, in which case we fall back to the default.
|
||||
function storedTheme(): Theme {
|
||||
if (typeof window === "undefined") return DEFAULT_THEME;
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
return isTheme(stored) ? stored : DEFAULT_THEME;
|
||||
} catch {
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: Props) {
|
||||
// The theme class ships on <html> in the static export, so the initial paint
|
||||
// never flashes. Reading storage here only matters when the stored preference
|
||||
// differs from that default.
|
||||
const [theme, setThemeState] = useState<Theme>(storedTheme);
|
||||
// Tracked separately so "system" keeps the skeleton palette in step with the
|
||||
// OS preference, which can change while the app is open.
|
||||
const [resolvedTheme, setResolvedTheme] =
|
||||
useState<ResolvedTheme>(DEFAULT_THEME);
|
||||
|
||||
const setTheme = useCallback((next: Theme) => {
|
||||
setThemeState(next);
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
// Preference is not persisted when storage is unavailable.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const apply = () => {
|
||||
const resolved = resolveTheme(theme);
|
||||
const root = document.documentElement;
|
||||
root.classList.remove(...THEME_CLASSES.filter((c) => c !== resolved));
|
||||
root.classList.add(resolved);
|
||||
root.style.colorScheme = resolved;
|
||||
setResolvedTheme(resolved);
|
||||
};
|
||||
|
||||
apply();
|
||||
|
||||
if (theme !== "system") return;
|
||||
|
||||
const query = window.matchMedia("(prefers-color-scheme: light)");
|
||||
query.addEventListener("change", apply);
|
||||
return () => query.removeEventListener("change", apply);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
<SkeletonTheme
|
||||
baseColor={SKELETON_COLORS[resolvedTheme].base}
|
||||
highlightColor={SKELETON_COLORS[resolvedTheme].highlight}
|
||||
>
|
||||
{children}
|
||||
</SkeletonTheme>
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext);
|
||||
@@ -18,9 +18,9 @@ import AnalyticsProvider, {
|
||||
} from "@/contexts/AnalyticsProvider";
|
||||
import DialogProvider from "@/contexts/DialogProvider";
|
||||
import ErrorBoundaryProvider from "@/contexts/ErrorBoundary";
|
||||
import { GlobalThemeProvider } from "@/contexts/GlobalThemeProvider";
|
||||
import InstanceSetupProvider from "@/contexts/InstanceSetupProvider";
|
||||
import { NavigationEvents } from "@/contexts/NavigationEvents";
|
||||
import { ThemeProvider } from "@/contexts/ThemeProvider";
|
||||
import { useSignupSource } from "@/hooks/useSignupSource";
|
||||
|
||||
const inter = localFont({
|
||||
@@ -43,7 +43,7 @@ export default function AppLayout({
|
||||
useSignupSource();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" className="dark" style={{ colorScheme: "dark" }}>
|
||||
<head>
|
||||
<GoogleTagManagerHeadScript />
|
||||
</head>
|
||||
@@ -51,7 +51,7 @@ export default function AppLayout({
|
||||
<Suspense fallback={<FullScreenLoading />}>
|
||||
<AnalyticsProvider>
|
||||
<DialogProvider>
|
||||
<GlobalThemeProvider>
|
||||
<ThemeProvider>
|
||||
<ErrorBoundaryProvider>
|
||||
<InstanceSetupProvider>
|
||||
<OIDCProvider>
|
||||
@@ -61,7 +61,7 @@ export default function AppLayout({
|
||||
</OIDCProvider>
|
||||
</InstanceSetupProvider>
|
||||
</ErrorBoundaryProvider>
|
||||
</GlobalThemeProvider>
|
||||
</ThemeProvider>
|
||||
</DialogProvider>
|
||||
<Toaster
|
||||
position="top-center"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useOidcUser } from "@axa-fr/react-oidc";
|
||||
import Button from "@components/Button";
|
||||
import { UserAvatar } from "@components/ui/UserAvatar";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { isNetBirdCloud } from "@utils/netbird";
|
||||
import { useIsSm, useIsXs } from "@utils/responsive";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { XIcon } from "lucide-react";
|
||||
@@ -24,9 +25,8 @@ import GroupsProvider from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import UsersProvider from "@/contexts/UsersProvider";
|
||||
import Navigation from "@/layouts/Navigation";
|
||||
import Header, { headerHeight } from "./Header";
|
||||
import { OnboardingProvider } from "@/modules/onboarding/OnboardingProvider";
|
||||
import { isNetBirdCloud } from "@utils/netbird";
|
||||
import Header, { headerHeight } from "./Header";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
@@ -175,7 +175,7 @@ function DashboardPageContent({
|
||||
}}
|
||||
>
|
||||
{!isRestricted && <Navigation hideOnMobile />}
|
||||
{children}
|
||||
<React.Fragment key={"page"}>{children}</React.Fragment>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function Navigation({
|
||||
? "w-auto max-w-[22rem]"
|
||||
: "w-[15rem] max-w-[15rem] min-w-[15rem] overflow-y-auto",
|
||||
isNavigationCollapsed &&
|
||||
"md:w-[70px] md:min-w-[70px] md:fixed md:overflow-hidden md:hover:w-[15rem] md:hover:max-w-[15rem] md:hover:min-w-[15rem] md:z-50",
|
||||
"md:w-[64px] md:min-w-[64px] md:fixed md:overflow-hidden md:hover:w-[15rem] md:hover:max-w-[15rem] md:hover:min-w-[15rem] md:z-50",
|
||||
)}
|
||||
style={{
|
||||
height: `calc(100vh - ${headerHeight + bannerHeight}px)`,
|
||||
@@ -71,7 +71,7 @@ export default function Navigation({
|
||||
className={cn(
|
||||
"flex flex-col pt-3 justify-between w-[15rem] max-w-[15rem] min-w-[15rem] transition-all",
|
||||
isNavigationCollapsed &&
|
||||
"md:w-[70px] md:min-w-[70px] md:group-hover/navigation:w-[15rem] md:group-hover/navigation:max-w-[15rem] md:group-hover/navigation:min-w-[15rem] md:overflow-x-clip",
|
||||
"md:w-[64px] md:min-w-[64px] md:group-hover/navigation:w-[15rem] md:group-hover/navigation:max-w-[15rem] md:group-hover/navigation:min-w-[15rem] md:overflow-x-clip",
|
||||
)}
|
||||
style={{
|
||||
height: !fullWidth
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function PageContainer({
|
||||
className={cn(
|
||||
className,
|
||||
"relative flex-auto overflow-auto bg-nb-gray z-1 focus:outline-none",
|
||||
isNavigationCollapsed && "md:pl-[70px]",
|
||||
isNavigationCollapsed && "md:pl-[64px]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -170,18 +170,22 @@ export default function AIProviderModal({
|
||||
onOpenChange,
|
||||
provider,
|
||||
}: Readonly<Props>) {
|
||||
const { addProvider, updateProvider, settings } = useAIProviders();
|
||||
const {
|
||||
addProvider,
|
||||
updateProvider,
|
||||
settings,
|
||||
bootstrapAgentNetworkSettings,
|
||||
} = useAIProviders();
|
||||
const { data: domains, isLoading: domainsLoading } = useFetchApi<
|
||||
ReverseProxyDomain[]
|
||||
>("/reverse-proxies/domains");
|
||||
const { catalog: catalogList, getById } = useProviderCatalog();
|
||||
|
||||
const isEdit = !!provider;
|
||||
// Cluster is no longer a per-provider concern: the backend pins it on
|
||||
// the account-level Settings row, seeded by the first provider create.
|
||||
// We auto-pick from the live /domains response and ship it as
|
||||
// bootstrap_cluster on the create payload — the backend ignores it on
|
||||
// subsequent creates and updates.
|
||||
// The endpoint lives on the account-level Settings row, bootstrapped once
|
||||
// via an explicit POST. We auto-pick a proxy cluster from the live /domains
|
||||
// response and, when the account isn't bootstrapped yet, POST it as the
|
||||
// settings proxy_address right before the first provider create.
|
||||
const settingsBootstrapped = !!settings;
|
||||
|
||||
const [tab, setTab] = useState<string>("provider");
|
||||
@@ -309,7 +313,7 @@ export default function AIProviderModal({
|
||||
|
||||
// Auto-pick the first validated cluster on first render once the
|
||||
// /domains response lands. Only matters for the first-create flow —
|
||||
// once settings is bootstrapped the bootstrap hint is ignored.
|
||||
// once settings is bootstrapped no further bootstrap happens.
|
||||
React.useEffect(() => {
|
||||
if (settingsBootstrapped) return;
|
||||
if (bootstrapCluster) return;
|
||||
@@ -393,8 +397,8 @@ export default function AIProviderModal({
|
||||
name.trim().length > 0 &&
|
||||
/^https?:\/\/[^\s]+$/i.test(upstreamUrl.trim()) &&
|
||||
apiKey.trim().length >= 4 &&
|
||||
// First-create requires a cluster pick; once settings is bootstrapped
|
||||
// the bootstrap hint is ignored so we don't need to gate on it.
|
||||
// First-create bootstraps the settings row and needs a cluster pick;
|
||||
// once settings is bootstrapped no cluster is involved.
|
||||
(settingsBootstrapped || bootstrapCluster.trim().length > 0);
|
||||
|
||||
// Restrict extraValues to keys the current catalog entry declares.
|
||||
@@ -453,11 +457,20 @@ export default function AIProviderModal({
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
// First create: bootstrap the account's endpoint before the provider
|
||||
// exists, as an explicit settings POST. A failure keeps the wizard open
|
||||
// (the provider isn't created either) so the operator sees the error and
|
||||
// can retry — this used to be a silent backend side effect.
|
||||
if (!settingsBootstrapped) {
|
||||
const bootstrapped = await bootstrapAgentNetworkSettings(
|
||||
bootstrapCluster.trim(),
|
||||
);
|
||||
if (!bootstrapped) return;
|
||||
}
|
||||
await addProvider({
|
||||
providerId,
|
||||
name,
|
||||
upstreamUrl,
|
||||
bootstrapCluster: settingsBootstrapped ? undefined : bootstrapCluster,
|
||||
apiKey,
|
||||
extraValues: sanitizedExtraValues,
|
||||
...identityOverrides,
|
||||
|
||||
@@ -69,9 +69,6 @@ export type APIProviderRequest = {
|
||||
provider_id: string;
|
||||
name: string;
|
||||
upstream_url: string;
|
||||
// bootstrap_cluster is only honoured on the first provider create
|
||||
// for the account; subsequent creates and all updates ignore it.
|
||||
bootstrap_cluster?: string;
|
||||
api_key?: string;
|
||||
extra_values?: Record<string, string>;
|
||||
identity_header_user_id?: string;
|
||||
@@ -86,9 +83,6 @@ export type ProviderConnectInput = {
|
||||
providerId: AIProviderId;
|
||||
name: string;
|
||||
upstreamUrl: string;
|
||||
// bootstrapCluster is only used on first-create to seed the
|
||||
// account-level Settings row; ignored after that.
|
||||
bootstrapCluster?: string;
|
||||
apiKey: string;
|
||||
extraValues?: Record<string, string>;
|
||||
identityHeaderUserId?: string;
|
||||
@@ -116,23 +110,44 @@ export type ProviderUpdateInput = {
|
||||
};
|
||||
|
||||
export type APIAgentNetworkSettings = {
|
||||
cluster: string;
|
||||
subdomain: string;
|
||||
// Bare hostname agents call for this account. Empty until bootstrapped.
|
||||
endpoint: string;
|
||||
// Declared cluster address of the proxy serving the endpoint: equal to
|
||||
// endpoint when a dedicated proxy serves the account, otherwise the shared
|
||||
// cluster the endpoint hangs one label beneath.
|
||||
proxy_address: string;
|
||||
dedicated: boolean;
|
||||
attribution_mode?: AttributionMode;
|
||||
default_user_monthly_budget?: number;
|
||||
enable_log_collection: boolean;
|
||||
enable_prompt_collection: boolean;
|
||||
redact_pii: boolean;
|
||||
access_log_retention_days?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// Absent until the account is bootstrapped — pre-bootstrap the backend
|
||||
// returns the defaults without a persisted row to date.
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
// APIAgentNetworkSettingsCreateRequest matches the POST /agent-network/settings
|
||||
// bootstrap body. Exactly one of proxy_address (labeled endpoint — the server
|
||||
// allocates the label beneath it) and endpoint (self-addressed dedicated
|
||||
// endpoint, claimed verbatim) must be set. The dashboard only uses the
|
||||
// labeled shape; the dedicated shape is API/BYOP-facing.
|
||||
export type APIAgentNetworkSettingsCreateRequest = {
|
||||
proxy_address?: string;
|
||||
endpoint?: string;
|
||||
};
|
||||
|
||||
// APIAgentNetworkSettingsRequest matches the PUT /agent-network/settings
|
||||
// body. Read-only fields (cluster, subdomain, endpoint, timestamps) are
|
||||
// stamped by the backend and never sent from the dashboard.
|
||||
// body. Every field is required — the PUT is a full replace, like the rest of
|
||||
// the REST API. The identity fields (endpoint, proxy_address) are assigned at
|
||||
// bootstrap (POST) and immutable: the request must echo the stored values
|
||||
// unchanged, and the backend rejects a mismatch (422) without applying
|
||||
// anything. Timestamps are stamped by the backend.
|
||||
export type APIAgentNetworkSettingsRequest = {
|
||||
endpoint: string;
|
||||
proxy_address: string;
|
||||
enable_log_collection: boolean;
|
||||
enable_prompt_collection: boolean;
|
||||
redact_pii: boolean;
|
||||
@@ -140,9 +155,9 @@ export type APIAgentNetworkSettingsRequest = {
|
||||
};
|
||||
|
||||
export type AgentNetworkSettings = {
|
||||
cluster: string;
|
||||
subdomain: string;
|
||||
endpoint: string;
|
||||
proxyAddress: string;
|
||||
dedicated: boolean;
|
||||
attributionMode: AttributionMode;
|
||||
defaultUserMonthlyBudget: number;
|
||||
enableLogCollection: boolean;
|
||||
@@ -219,7 +234,6 @@ function toCreateRequest(input: ProviderConnectInput): APIProviderRequest {
|
||||
provider_id: input.providerId,
|
||||
name: input.name,
|
||||
upstream_url: input.upstreamUrl,
|
||||
bootstrap_cluster: input.bootstrapCluster,
|
||||
api_key: input.apiKey,
|
||||
extra_values: input.extraValues,
|
||||
identity_header_user_id: input.identityHeaderUserId,
|
||||
@@ -233,9 +247,9 @@ function toCreateRequest(input: ProviderConnectInput): APIProviderRequest {
|
||||
|
||||
function settingsFromAPI(s: APIAgentNetworkSettings): AgentNetworkSettings {
|
||||
return {
|
||||
cluster: s.cluster,
|
||||
subdomain: s.subdomain,
|
||||
endpoint: s.endpoint,
|
||||
proxyAddress: s.proxy_address,
|
||||
dedicated: s.dedicated ?? false,
|
||||
attributionMode: s.attribution_mode ?? "priority",
|
||||
defaultUserMonthlyBudget: s.default_user_monthly_budget ?? 0,
|
||||
enableLogCollection: s.enable_log_collection ?? false,
|
||||
@@ -247,8 +261,13 @@ function settingsFromAPI(s: APIAgentNetworkSettings): AgentNetworkSettings {
|
||||
|
||||
function settingsToRequest(
|
||||
s: AgentNetworkSettingsUpdate,
|
||||
identity: Pick<AgentNetworkSettings, "endpoint" | "proxyAddress">,
|
||||
): APIAgentNetworkSettingsRequest {
|
||||
return {
|
||||
// Required echo of the immutable identity — always the stored values,
|
||||
// never user input, so the backend's mismatch rejection can't trigger.
|
||||
endpoint: identity.endpoint,
|
||||
proxy_address: identity.proxyAddress,
|
||||
enable_log_collection: s.enableLogCollection,
|
||||
enable_prompt_collection: s.enablePromptCollection,
|
||||
redact_pii: s.redactPii,
|
||||
@@ -508,6 +527,12 @@ type AIProvidersContextValue = {
|
||||
updateAgentNetworkSettings: (
|
||||
updates: AgentNetworkSettingsUpdate,
|
||||
) => Promise<boolean>;
|
||||
// Bootstraps the account's settings row, assigning the endpoint as a
|
||||
// server-allocated label beneath the given proxy cluster address. One-time
|
||||
// per account; resolves to true once the row exists (a lost race against a
|
||||
// concurrent bootstrap counts — the row is there), false on failure (errors
|
||||
// are notified internally).
|
||||
bootstrapAgentNetworkSettings: (proxyAddress: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
const AIProvidersContext = createContext<AIProvidersContextValue | null>(null);
|
||||
@@ -521,10 +546,12 @@ export function useAIProviders() {
|
||||
}
|
||||
|
||||
// useAgentNetworkSettings fetches the account-level agent-network settings.
|
||||
// Returns null until the first provider is created — newer backends respond
|
||||
// 200 + JSON null while no settings row exists; older backends respond 404,
|
||||
// which we still tolerate via ignoreError so older deploys don't surface
|
||||
// a spurious error in the empty state.
|
||||
// Returns null until the account is bootstrapped via the explicit settings
|
||||
// POST. Backends signal the unbootstrapped state differently by age: current
|
||||
// ones respond 200 with the defaults and an empty endpoint/proxy_address,
|
||||
// older ones 200 + JSON null, and the oldest 404 — tolerated via ignoreError
|
||||
// so old deploys don't surface a spurious error in the empty state. All
|
||||
// three normalize to null here.
|
||||
export function useAgentNetworkSettings() {
|
||||
const { enabled: agentNetworkEnabled } = useAgentNetworkMode();
|
||||
const { data, error, isLoading, mutate } =
|
||||
@@ -534,11 +561,14 @@ export function useAgentNetworkSettings() {
|
||||
true,
|
||||
agentNetworkEnabled,
|
||||
);
|
||||
const settings = useMemo<AgentNetworkSettings | null>(
|
||||
() => (data ? settingsFromAPI(data) : null),
|
||||
[data],
|
||||
);
|
||||
const notFound = !!error && (error as { code?: number }).code === 404;
|
||||
// SWR keeps the previous data alongside the error (keepPreviousData), so a
|
||||
// later 404 must not expose the stale settings; other transient errors keep
|
||||
// them, which is what keepPreviousData is for.
|
||||
const settings = useMemo<AgentNetworkSettings | null>(
|
||||
() => (data && data.endpoint && !notFound ? settingsFromAPI(data) : null),
|
||||
[data, notFound],
|
||||
);
|
||||
return {
|
||||
settings,
|
||||
isLoading: isLoading && !notFound,
|
||||
@@ -600,6 +630,13 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
const settingsApi = useApiCall<APIAgentNetworkSettings>(
|
||||
"/agent-network/settings",
|
||||
);
|
||||
// Separate call with ignoreError: the bootstrap treats a 409 (a concurrent
|
||||
// bootstrap won) as success, so the global error surface must not fire
|
||||
// before we get to classify the failure.
|
||||
const settingsBootstrapApi = useApiCall<APIAgentNetworkSettings>(
|
||||
"/agent-network/settings",
|
||||
true,
|
||||
);
|
||||
|
||||
const providers = useMemo<AIProvider[]>(
|
||||
() => (apiProviders ?? []).map(fromAPI),
|
||||
@@ -631,10 +668,6 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
try {
|
||||
const created = await providersApi.post(toCreateRequest(input));
|
||||
await mutate();
|
||||
// First-create bootstraps account-level settings on the
|
||||
// backend; refresh so the page header flips out of the empty
|
||||
// state without a manual reload.
|
||||
await mutateSettings();
|
||||
notify({
|
||||
title: "AI provider connected",
|
||||
description: `${created.name} is now available on your agent network endpoint.`,
|
||||
@@ -648,7 +681,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
[providersApi, mutate, mutateSettings],
|
||||
[providersApi, mutate],
|
||||
);
|
||||
|
||||
const updateProvider = useCallback(
|
||||
@@ -676,7 +709,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
updates.metadataDisabled ?? existing.metadata_disabled,
|
||||
models: updates.models
|
||||
? toAPIModels(updates.models)
|
||||
: existing.models ?? [],
|
||||
: (existing.models ?? []),
|
||||
enabled: updates.enabled ?? existing.enabled,
|
||||
};
|
||||
try {
|
||||
@@ -761,7 +794,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
guardrail_ids: updates.guardrailIds ?? existing.guardrail_ids ?? [],
|
||||
limits: updates.limits
|
||||
? policyLimitsToAPI(updates.limits)
|
||||
: existing.limits ?? policyLimitsToAPI(EMPTY_POLICY_LIMITS),
|
||||
: (existing.limits ?? policyLimitsToAPI(EMPTY_POLICY_LIMITS)),
|
||||
};
|
||||
try {
|
||||
await policiesApi.put(merged, `/${id}`);
|
||||
@@ -959,10 +992,46 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
[budgetRulesApi, mutateBudgetRules],
|
||||
);
|
||||
|
||||
const bootstrapAgentNetworkSettings = useCallback(
|
||||
async (proxyAddress: string) => {
|
||||
const body: APIAgentNetworkSettingsCreateRequest = {
|
||||
proxy_address: proxyAddress,
|
||||
};
|
||||
try {
|
||||
await settingsBootstrapApi.post(body);
|
||||
} catch (err) {
|
||||
const code = (err as { code?: number })?.code;
|
||||
if (code !== 409) {
|
||||
notify({
|
||||
title: "Failed to set up the agent network endpoint",
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
// 409: a concurrent bootstrap won. The row exists, which is what
|
||||
// the caller wanted — fall through to the refresh.
|
||||
}
|
||||
await mutateSettings();
|
||||
return true;
|
||||
},
|
||||
[settingsBootstrapApi, mutateSettings],
|
||||
);
|
||||
|
||||
const updateAgentNetworkSettings = useCallback(
|
||||
async (updates: AgentNetworkSettingsUpdate) => {
|
||||
// The PUT must echo the immutable identity fields (endpoint,
|
||||
// proxy_address) alongside the mutable ones. Without a loaded settings
|
||||
// row there is nothing to echo — and no row to update; the backend
|
||||
// would 404 the PUT anyway.
|
||||
if (!settings) {
|
||||
notify({
|
||||
title: "Failed to update account controls",
|
||||
description: "Agent Network has not been set up yet.",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await settingsApi.put(settingsToRequest(updates));
|
||||
await settingsApi.put(settingsToRequest(updates, settings));
|
||||
await mutateSettings();
|
||||
notify({
|
||||
title: "Account controls updated",
|
||||
@@ -977,7 +1046,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[settingsApi, mutateSettings],
|
||||
[settings, settingsApi, mutateSettings],
|
||||
);
|
||||
|
||||
const value = useMemo<AIProvidersContextValue>(
|
||||
@@ -1009,6 +1078,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
toggleBudgetRule,
|
||||
deleteBudgetRule,
|
||||
updateAgentNetworkSettings,
|
||||
bootstrapAgentNetworkSettings,
|
||||
}),
|
||||
[
|
||||
providers,
|
||||
@@ -1038,6 +1108,7 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
toggleBudgetRule,
|
||||
deleteBudgetRule,
|
||||
updateAgentNetworkSettings,
|
||||
bootstrapAgentNetworkSettings,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Loaded 20 of 42 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user