mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7fd24244d | ||
|
|
9230b3e4e3 | ||
|
|
19ad8d6806 | ||
|
|
38fc2970e6 | ||
|
|
4ec4d29e84 |
No files matched your search
@@ -193,7 +193,7 @@ export default function AgentNetworkProvidersPage() {
|
||||
Anthropic through one keyless endpoint, accessible only via
|
||||
NetBird’s tunnel.
|
||||
<InlineLink
|
||||
href={"https://anet.demo.netbird.io/"}
|
||||
href={"https://docs.netbird.io/agent-network/providers"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Learn more
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
import AddPeerButton from "@components/ui/AddPeerButton";
|
||||
import AddPeerDropdown from "@components/ui/AddPeerDropdown";
|
||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -8,17 +8,10 @@ import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
|
||||
type Props = {
|
||||
showBackground?: boolean;
|
||||
// When set, tailors the empty-state copy and threads isUserDevice
|
||||
// through AddPeerButton so the right Install NetBird flow opens:
|
||||
// true → User Devices empty state (browser/SSO flow, mobile tabs).
|
||||
// false → Servers empty state (setup-key flow, no mobile tabs).
|
||||
// undefined → legacy/global empty state (no kind preference).
|
||||
isUserDevice?: boolean;
|
||||
};
|
||||
|
||||
export const NoPeersGettingStarted = ({
|
||||
showBackground = true,
|
||||
isUserDevice,
|
||||
}: Readonly<Props>) => {
|
||||
return (
|
||||
<GetStartedTest
|
||||
@@ -35,7 +28,7 @@ export const NoPeersGettingStarted = ({
|
||||
"It looks like you don't have any connected machines.\n" +
|
||||
"Get started by adding one to your network."
|
||||
}
|
||||
button={<AddPeerButton isUserDevice={isUserDevice} />}
|
||||
button={<AddPeerDropdown />}
|
||||
learnMore={
|
||||
<>
|
||||
Learn more in our{" "}
|
||||
|
||||
@@ -28,8 +28,9 @@ import {
|
||||
PlusCircle,
|
||||
PlusIcon,
|
||||
Sparkles,
|
||||
UploadIcon,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
|
||||
import {
|
||||
ReverseProxyDomain,
|
||||
@@ -100,6 +101,8 @@ function upstreamUrlPlaceholder(providerId: AIProviderId): string {
|
||||
return "https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/openai";
|
||||
case "vercel_ai_gateway":
|
||||
return "https://ai-gateway.vercel.sh";
|
||||
case "vertex_ai_api":
|
||||
return "https://aiplatform.googleapis.com";
|
||||
case "openrouter":
|
||||
return "https://openrouter.ai/api/v1";
|
||||
case "litellm_proxy":
|
||||
@@ -168,6 +171,25 @@ export default function AIProviderModal({
|
||||
const [apiKey, setApiKey] = useState(isEdit ? "••••••••" : "");
|
||||
const [bootstrapCluster, setBootstrapCluster] = useState<string>("");
|
||||
const [models, setModels] = useState<ProviderModel[]>(provider?.models ?? []);
|
||||
|
||||
// Vertex AI authenticates with a service-account JSON key, not an API key.
|
||||
// We upload the file and store it base64-encoded in apiKey (the server
|
||||
// decodes the base64 JSON); keyFileName tracks the uploaded file for display.
|
||||
const [keyFileName, setKeyFileName] = useState<string | null>(null);
|
||||
const keyFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onJsonKeyUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
let binary = "";
|
||||
bytes.forEach((b) => (binary += String.fromCharCode(b)));
|
||||
// The server expects the JSON key prefixed with "keyfile::".
|
||||
setApiKey("keyfile::" + window.btoa(binary));
|
||||
setKeyFileName(file.name);
|
||||
// Clear so picking the same file again still fires onChange.
|
||||
e.target.value = "";
|
||||
};
|
||||
// extraValues holds operator-typed values for catalog-declared extra
|
||||
// headers (e.g. Portkey's x-portkey-config). Keyed by wire header
|
||||
// name. Catalog (catalog.extra_headers) decides which inputs render
|
||||
@@ -267,6 +289,9 @@ export default function AIProviderModal({
|
||||
if (upstreamUrl) return;
|
||||
if (!catalog) return;
|
||||
if (!catalog.default_host) return;
|
||||
// Vertex's catalog host is templated (<region>-…); skip the prefill so the
|
||||
// clean https://aiplatform.googleapis.com placeholder shows instead.
|
||||
if (catalog.id === "vertex_ai_api") return;
|
||||
setUpstreamUrl(`https://${catalog.default_host}`);
|
||||
}, [provider, upstreamUrl, catalog]);
|
||||
|
||||
@@ -519,6 +544,10 @@ export default function AIProviderModal({
|
||||
onChange={(v) => {
|
||||
const next = v as AIProviderId;
|
||||
setProviderId(next);
|
||||
// The credential differs per provider (API key vs Vertex
|
||||
// JSON upload), so clear it when switching.
|
||||
setApiKey("");
|
||||
setKeyFileName(null);
|
||||
const c = getById(next);
|
||||
if (c) {
|
||||
setName(c.name);
|
||||
@@ -527,7 +556,11 @@ export default function AIProviderModal({
|
||||
// endpoint). Don't pre-fill "https://" — let the
|
||||
// placeholder hint them what to type instead.
|
||||
setUpstreamUrl(
|
||||
c.default_host ? `https://${c.default_host}` : "",
|
||||
next === "vertex_ai_api"
|
||||
? "https://aiplatform.googleapis.com"
|
||||
: c.default_host
|
||||
? `https://${c.default_host}`
|
||||
: "",
|
||||
);
|
||||
setModels([]);
|
||||
// Auto-seed the identity inputs from the
|
||||
@@ -571,41 +604,89 @@ export default function AIProviderModal({
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow
|
||||
label={
|
||||
<>
|
||||
Provider API key
|
||||
<HelpTooltip
|
||||
content={
|
||||
<>
|
||||
NetBird injects it as{" "}
|
||||
<code className={"text-nb-gray-200"}>
|
||||
{catalog?.auth_header_template}
|
||||
</code>{" "}
|
||||
on every upstream request, so agents never see the
|
||||
key.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
helpText={"The API key issued by the provider."}
|
||||
>
|
||||
<Input
|
||||
type={"password"}
|
||||
showPasswordToggle
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
customPrefix={<KeyRound size={14} />}
|
||||
placeholder={
|
||||
providerId === "openai_api"
|
||||
? "sk-..."
|
||||
: providerId === "anthropic_api"
|
||||
? "sk-ant-..."
|
||||
: "Paste your API key"
|
||||
{providerId === "vertex_ai_api" ? (
|
||||
<FormRow
|
||||
label={
|
||||
<>
|
||||
Service account JSON key
|
||||
<HelpTooltip
|
||||
content={
|
||||
<>
|
||||
Upload the Vertex AI service account JSON key.
|
||||
NetBird base64-encodes it and prefixes it with{" "}
|
||||
<code className={"text-nb-gray-200"}>keyfile::</code>{" "}
|
||||
before injecting it on every upstream request, so
|
||||
agents never see the key.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</FormRow>
|
||||
helpText={"Upload the service account JSON key file."}
|
||||
>
|
||||
<div className={"flex items-center gap-3"}>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => keyFileInputRef.current?.click()}
|
||||
>
|
||||
<UploadIcon size={14} />
|
||||
{keyFileName || (isEdit && apiKey === "••••••••")
|
||||
? "Replace JSON key"
|
||||
: "Upload JSON key"}
|
||||
</Button>
|
||||
<span className={"text-xs text-nb-gray-300 truncate"}>
|
||||
{keyFileName
|
||||
? keyFileName
|
||||
: isEdit && apiKey === "••••••••"
|
||||
? "A key is already stored"
|
||||
: "No file selected"}
|
||||
</span>
|
||||
<input
|
||||
ref={keyFileInputRef}
|
||||
type={"file"}
|
||||
accept={"application/json,.json"}
|
||||
className={"hidden"}
|
||||
onChange={onJsonKeyUpload}
|
||||
/>
|
||||
</div>
|
||||
</FormRow>
|
||||
) : (
|
||||
<FormRow
|
||||
label={
|
||||
<>
|
||||
Provider API key
|
||||
<HelpTooltip
|
||||
content={
|
||||
<>
|
||||
NetBird injects it as{" "}
|
||||
<code className={"text-nb-gray-200"}>
|
||||
{catalog?.auth_header_template}
|
||||
</code>{" "}
|
||||
on every upstream request, so agents never see the
|
||||
key.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
helpText={"The API key issued by the provider."}
|
||||
>
|
||||
<Input
|
||||
type={"password"}
|
||||
showPasswordToggle
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
customPrefix={<KeyRound size={14} />}
|
||||
placeholder={
|
||||
providerId === "openai_api"
|
||||
? "sk-..."
|
||||
: providerId === "anthropic_api"
|
||||
? "sk-ant-..."
|
||||
: "Paste your API key"
|
||||
}
|
||||
/>
|
||||
</FormRow>
|
||||
)}
|
||||
{(catalog?.extra_headers ?? []).map((h) => {
|
||||
const ui = EXTRA_HEADER_UI[h.name] ?? fallbackExtraHeaderUI(h.name);
|
||||
return (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Modal, ModalContent } from "@components/modal/Modal";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SmallParagraph from "@components/SmallParagraph";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
import { SelectDropdown } from "@components/select/SelectDropdown";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import { Plug } from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -69,6 +70,11 @@ export function AgentConnectTabs({
|
||||
const [claudeMode, setClaudeMode] = React.useState<"config" | "shell">(
|
||||
"config",
|
||||
);
|
||||
// Which backend the Claude Code config targets — Anthropic API direct, or
|
||||
// via Vertex AI / Bedrock. Switched in-tab instead of separate tabs.
|
||||
const [claudeProvider, setClaudeProvider] = React.useState<
|
||||
"anthropic" | "vertex" | "bedrock"
|
||||
>("anthropic");
|
||||
|
||||
return (
|
||||
<Tabs key={defaultTab} defaultValue={defaultTab} className={"mt-2"}>
|
||||
@@ -81,42 +87,95 @@ export function AgentConnectTabs({
|
||||
|
||||
<TabsContent value={"claude-code"}>
|
||||
<div className={contentClassName}>
|
||||
<div className={"flex items-center justify-between gap-3 mb-2"}>
|
||||
<SmallParagraph className={"!mb-0"}>
|
||||
{claudeMode === "config"
|
||||
? "Add to ~/.claude/settings.json:"
|
||||
: "Run in your shell:"}
|
||||
</SmallParagraph>
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={() =>
|
||||
setClaudeMode(claudeMode === "config" ? "shell" : "config")
|
||||
<div className={"mb-3"}>
|
||||
<SelectDropdown
|
||||
value={claudeProvider}
|
||||
onChange={(v) =>
|
||||
setClaudeProvider(v as "anthropic" | "vertex" | "bedrock")
|
||||
}
|
||||
className={
|
||||
"shrink-0 mr-2 text-[11px] text-white hover:underline underline-offset-2 cursor-pointer"
|
||||
}
|
||||
>
|
||||
{claudeMode === "config" ? "Shell" : "JSON"}
|
||||
</button>
|
||||
options={[
|
||||
{ label: "Anthropic API", value: "anthropic" },
|
||||
{ label: "Vertex AI", value: "vertex" },
|
||||
{ label: "Bedrock", value: "bedrock" },
|
||||
]}
|
||||
showValues={false}
|
||||
className={"w-[160px]"}
|
||||
/>
|
||||
</div>
|
||||
<Snippet
|
||||
lines={
|
||||
claudeMode === "config"
|
||||
? [
|
||||
`{`,
|
||||
` "apiKeyHelper": "echo '-'",`,
|
||||
` "env": {`,
|
||||
` "ANTHROPIC_BASE_URL": "${baseUrl}"`,
|
||||
` }`,
|
||||
`}`,
|
||||
]
|
||||
: [
|
||||
`export ANTHROPIC_BASE_URL=${baseUrl}`,
|
||||
`export ANTHROPIC_API_KEY=none`,
|
||||
`claude`,
|
||||
]
|
||||
}
|
||||
/>
|
||||
|
||||
{claudeProvider === "anthropic" && (
|
||||
<>
|
||||
<div className={"flex items-center justify-between gap-3 mb-2"}>
|
||||
<SmallParagraph className={"!mb-0"}>
|
||||
{claudeMode === "config"
|
||||
? "Add to ~/.claude/settings.json:"
|
||||
: "Run in your shell:"}
|
||||
</SmallParagraph>
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={() =>
|
||||
setClaudeMode(claudeMode === "config" ? "shell" : "config")
|
||||
}
|
||||
className={
|
||||
"shrink-0 mr-2 text-[11px] text-white hover:underline underline-offset-2 cursor-pointer"
|
||||
}
|
||||
>
|
||||
{claudeMode === "config" ? "Shell" : "JSON"}
|
||||
</button>
|
||||
</div>
|
||||
<Snippet
|
||||
lines={
|
||||
claudeMode === "config"
|
||||
? [
|
||||
`{`,
|
||||
` "apiKeyHelper": "echo '-'",`,
|
||||
` "env": {`,
|
||||
` "ANTHROPIC_BASE_URL": "${baseUrl}"`,
|
||||
` }`,
|
||||
`}`,
|
||||
]
|
||||
: [
|
||||
`export ANTHROPIC_BASE_URL=${baseUrl}`,
|
||||
`export ANTHROPIC_API_KEY=none`,
|
||||
`claude`,
|
||||
]
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{claudeProvider === "vertex" && (
|
||||
<Snippet
|
||||
caption={"Add to ~/.claude/settings.json:"}
|
||||
lines={[
|
||||
`{`,
|
||||
` "env": {`,
|
||||
` "CLOUD_ML_REGION": "global",`,
|
||||
` "ANTHROPIC_VERTEX_PROJECT_ID": "<your-gcp-project-id>",`,
|
||||
` "CLAUDE_CODE_USE_VERTEX": "1",`,
|
||||
` "CLAUDE_CODE_SKIP_VERTEX_AUTH": "1",`,
|
||||
` "ANTHROPIC_VERTEX_BASE_URL": "${baseUrl}/v1"`,
|
||||
` }`,
|
||||
`}`,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{claudeProvider === "bedrock" && (
|
||||
<Snippet
|
||||
caption={"Add to ~/.claude/settings.json:"}
|
||||
lines={[
|
||||
`{`,
|
||||
` "env": {`,
|
||||
` "ANTHROPIC_MODEL": "<your-bedrock-model-id>",`,
|
||||
` "ANTHROPIC_BEDROCK_BASE_URL": "${baseUrl}/bedrock",`,
|
||||
` "CLAUDE_CODE_USE_BEDROCK": "1",`,
|
||||
` "CLAUDE_CODE_SKIP_BEDROCK_AUTH": "1"`,
|
||||
` }`,
|
||||
`}`,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -518,10 +518,7 @@ export default function PeersTable({
|
||||
// still shows.
|
||||
hasServerSideFilters={kind !== undefined && (peers?.length ?? 0) > 0}
|
||||
getStartedCard={
|
||||
<NoPeersGettingStarted
|
||||
showBackground={true}
|
||||
isUserDevice={kind ? kind === "users" : undefined}
|
||||
/>
|
||||
<NoPeersGettingStarted showBackground={true} />
|
||||
}
|
||||
rightSide={() => (
|
||||
<>
|
||||
|
||||
+5
-3
@@ -129,17 +129,19 @@ export default function useFetchApi<T>(
|
||||
const handleErrors = useApiErrorHandling(ignoreError);
|
||||
const { globalApiParams } = useApplicationContext();
|
||||
|
||||
const cacheKey = options?.key ? [url, options?.key] : url;
|
||||
const cacheKey = !allowFetch
|
||||
? null
|
||||
: options?.key
|
||||
? [url, options?.key]
|
||||
: url;
|
||||
const fetchFn = options?.key
|
||||
? async ([url]: [url: string]) => {
|
||||
if (!allowFetch) return;
|
||||
return apiRequest<T>(fetch, "GET", url, undefined, {
|
||||
...options,
|
||||
globalParams: globalApiParams,
|
||||
}).catch((err) => handleErrors(err as ErrorResponse));
|
||||
}
|
||||
: async (url: string) => {
|
||||
if (!allowFetch) return;
|
||||
return apiRequest<T>(fetch, "GET", url, undefined, {
|
||||
...options,
|
||||
globalParams: globalApiParams,
|
||||
|
||||
Reference in new issue
Block a user