mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30dd75d95d | ||
|
|
bfa064a19d | ||
|
|
db73d18370 | ||
|
|
ba32178425 | ||
|
|
4ea4fd4a4c | ||
|
|
deaae9159c | ||
|
|
d7fd24244d | ||
|
|
9230b3e4e3 |
No files matched your search
@@ -141,8 +141,18 @@ CSP_CONNECT_SRC=$(echo $CSP_CONNECT_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' |
|
||||
CSP_FRAME_SRC=$(echo $CSP_FRAME_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
|
||||
CSP_SCRIPT_SRC=$(echo $CSP_SCRIPT_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
|
||||
|
||||
# upgrade-insecure-requests tells the browser to rewrite every http subresource,
|
||||
# fetch and WebSocket to https/wss. That is correct for https deployments but breaks
|
||||
# plain-http ones (e.g. local or self-hosted over http), where it would rewrite the
|
||||
# working http/ws endpoints to unreachable https/wss. Emit it only when the backend
|
||||
# is served over https, matching the ws:// vs wss:// scheme logic above.
|
||||
CSP_UPGRADE_INSECURE=" upgrade-insecure-requests;"
|
||||
if [[ "$NETBIRD_MGMT_API_ENDPOINT" == http://* || "$AUTH_AUTHORITY" == http://* ]]; then
|
||||
CSP_UPGRADE_INSECURE=""
|
||||
fi
|
||||
|
||||
# Update CSP in nginx config
|
||||
CSP_POLICY="default-src 'none'; connect-src 'self' $CSP_CONNECT_SRC; frame-src 'self' $CSP_FRAME_SRC; script-src 'self' 'wasm-unsafe-eval' $CSP_SCRIPT_SRC; font-src 'self'; img-src * data:; manifest-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests;"
|
||||
CSP_POLICY="default-src 'none'; connect-src 'self' $CSP_CONNECT_SRC; frame-src 'self' $CSP_FRAME_SRC; script-src 'self' 'wasm-unsafe-eval' $CSP_SCRIPT_SRC; font-src 'self'; img-src * data:; manifest-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'; base-uri 'self'; form-action 'self';$CSP_UPGRADE_INSECURE"
|
||||
CSP_HEADER="add_header Content-Security-Policy \"$CSP_POLICY\" always;"
|
||||
|
||||
echo "CSP header: $CSP_HEADER"
|
||||
@@ -161,4 +171,25 @@ for f in $(grep -R -l AUTH_SUPPORTED_SCOPES /usr/share/nginx/html); do
|
||||
cp "$f" "$f".copy
|
||||
envsubst "$ENV_STR" < "$f".copy > "$f"
|
||||
rm "$f".copy
|
||||
done
|
||||
done
|
||||
|
||||
# Reload nginx so the patched CSP header takes effect.
|
||||
# supervisord starts nginx (priority 100) before this script (priority 201) and
|
||||
# never reloads it, so without this nginx keeps serving the static default.conf
|
||||
# CSP that has no connect-src, breaking OIDC discovery over plain HTTP.
|
||||
reloaded=false
|
||||
for i in $(seq 1 10); do
|
||||
if nginx -s reload 2>/dev/null; then
|
||||
echo "Reloaded nginx to apply updated CSP configuration"
|
||||
reloaded=true
|
||||
break
|
||||
fi
|
||||
echo "Waiting for nginx to be ready before reload (attempt $i)..."
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [[ "$reloaded" != true ]]; then
|
||||
echo "Failed to reload nginx after patching CSP header" >&2
|
||||
nginx -t || true
|
||||
exit 1
|
||||
fi
|
||||
@@ -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
|
||||
|
||||
@@ -40,6 +40,11 @@ export default function UsageAndLogsPage() {
|
||||
const initialTab = VALID_TABS.has(queryTab) ? queryTab : TAB_USAGE;
|
||||
const [tab, setTab] = useState(initialTab);
|
||||
|
||||
// Access-log view mode: flat per-request rows, or grouped by provider session.
|
||||
// Each mode hits its own endpoint and the provider is remounted on toggle
|
||||
// (keyed below) so the two response shapes never cross.
|
||||
const [groupBySession, setGroupBySession] = useState(false);
|
||||
|
||||
// Default the access-log view to the last 14 days. Computed once on mount so
|
||||
// the window is stable across re-renders; resetting filters returns here.
|
||||
const defaultAccessLogFilters = useMemo(
|
||||
@@ -120,11 +125,19 @@ export default function UsageAndLogsPage() {
|
||||
<TabsContent value={TAB_ACCESS_LOGS} className={"pb-8"}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<ServerPaginationProvider
|
||||
url={"/agent-network/access-logs"}
|
||||
key={groupBySession ? "sessions" : "flat"}
|
||||
url={
|
||||
groupBySession
|
||||
? "/agent-network/access-log-sessions"
|
||||
: "/agent-network/access-logs"
|
||||
}
|
||||
defaultPageSize={25}
|
||||
defaultFilters={defaultAccessLogFilters}
|
||||
>
|
||||
<AgentAccessLogTable />
|
||||
<AgentAccessLogTable
|
||||
grouped={groupBySession}
|
||||
onGroupedChange={setGroupBySession}
|
||||
/>
|
||||
</ServerPaginationProvider>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { VerticalTabs } from "@components/VerticalTabs";
|
||||
import {
|
||||
AlertOctagonIcon,
|
||||
ChartNoAxesCombined,
|
||||
FingerprintIcon,
|
||||
FolderGit2Icon,
|
||||
KeyRound,
|
||||
@@ -27,6 +28,7 @@ import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab";
|
||||
import PermissionsTab from "@/modules/settings/PermissionsTab";
|
||||
import SetupKeysTab from "@/modules/settings/SetupKeysTab";
|
||||
import GroupsSettings from "@/modules/settings/GroupsSettings";
|
||||
import MetricsTab from "@/modules/settings/MetricsTab";
|
||||
import {
|
||||
CloudSettingsTabContent,
|
||||
CloudSettingsTabTrigger,
|
||||
@@ -92,6 +94,10 @@ export default function NetBirdSettings() {
|
||||
<MonitorSmartphoneIcon size={14} />
|
||||
Clients
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="metrics">
|
||||
<ChartNoAxesCombined size={14} />
|
||||
Metrics
|
||||
</VerticalTabs.Trigger>
|
||||
</>
|
||||
)}
|
||||
<CloudSettingsTabTrigger />
|
||||
@@ -110,6 +116,7 @@ export default function NetBirdSettings() {
|
||||
{account && <GroupsSettings account={account} />}
|
||||
{account && <NetworkSettingsTab account={account} />}
|
||||
{account && <ClientSettingsTab account={account} />}
|
||||
{account && <MetricsTab account={account} />}
|
||||
{account && <DangerZoneTab account={account} />}
|
||||
<CloudSettingsTabContent />
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface Account {
|
||||
embedded_idp_enabled?: boolean;
|
||||
auto_update_version: string;
|
||||
auto_update_always: boolean;
|
||||
metrics_push_enabled?: boolean;
|
||||
local_auth_disabled?: boolean;
|
||||
local_mfa_enabled?: boolean;
|
||||
ipv6_enabled_groups?: string[];
|
||||
|
||||
@@ -223,8 +223,7 @@ export default function ActivityDescription({ event }: Props) {
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
User <Value>{event.meta.username}</Value>{" "}
|
||||
<Value>{event.meta.email}</Value>
|
||||
was blocked
|
||||
<Value>{event.meta.email}</Value> was blocked
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -232,8 +231,7 @@ export default function ActivityDescription({ event }: Props) {
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
User <Value>{event.meta.username}</Value>{" "}
|
||||
<Value>{event.meta.email}</Value>
|
||||
was unblocked
|
||||
<Value>{event.meta.email}</Value> was unblocked
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -359,12 +357,19 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "peer.login.expire")
|
||||
if (event.activity_code == "peer.login.expire") {
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Login of the peer <Value>{m.name}</Value> is expired
|
||||
Login of the peer <Value>{m.name}</Value> expired
|
||||
{m.reason && (
|
||||
<>
|
||||
{" "}
|
||||
due to <Value>{m.reason}</Value>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (event.activity_code == "peer.ssh.disable")
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Button from "@components/Button";
|
||||
import { Callout } from "@components/Callout";
|
||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { HelpTooltip } from "@components/HelpTooltip";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
MinusCircleIcon,
|
||||
PlusCircle,
|
||||
PlusIcon,
|
||||
ShieldOffIcon,
|
||||
Sparkles,
|
||||
UploadIcon,
|
||||
} from "lucide-react";
|
||||
@@ -109,6 +111,8 @@ function upstreamUrlPlaceholder(providerId: AIProviderId): string {
|
||||
return "https://your-litellm-host";
|
||||
case "portkey":
|
||||
return "https://api.portkey.ai";
|
||||
case "vllm":
|
||||
return "https://your-vllm-host:8000";
|
||||
case "custom":
|
||||
return "https://your-llm-host";
|
||||
default:
|
||||
@@ -130,6 +134,8 @@ function upstreamUrlHelpText(providerId: AIProviderId): string {
|
||||
return "Vercel AI Gateway uses a fixed endpoint; only the API key varies by operator. Apps choose the upstream provider with the model prefix, e.g. openai/gpt-5.4 or anthropic/claude-opus-4.6.";
|
||||
case "openrouter":
|
||||
return "OpenRouter uses a fixed endpoint, openrouter.ai/api/v1; apps choose the upstream provider via the model prefix, e.g. anthropic/claude-* or openai/gpt-*.";
|
||||
case "vllm":
|
||||
return "Your local vLLM server's OpenAI-compatible base URL.";
|
||||
default:
|
||||
return "Where NetBird forwards the traffic.";
|
||||
}
|
||||
@@ -208,8 +214,14 @@ export default function AIProviderModal({
|
||||
const [identityHeaderGroups, setIdentityHeaderGroups] = useState<string>(
|
||||
provider?.identityHeaderGroups ?? "",
|
||||
);
|
||||
const [skipTlsVerification, setSkipTlsVerification] = useState<boolean>(
|
||||
provider?.skipTlsVerification ?? false,
|
||||
);
|
||||
|
||||
const catalog = getById(providerId);
|
||||
// Custom-kind providers (the generic "Custom" entry and named self-hosted
|
||||
// ones like vLLM) share the self-hosted extras, e.g. Skip TLS verification.
|
||||
const isCustomKind = catalog?.kind === "custom";
|
||||
const customizableHeaderPair =
|
||||
catalog?.identity_injection?.header_pair?.customizable === true;
|
||||
const customizableJsonMetadata =
|
||||
@@ -327,6 +339,7 @@ export default function AIProviderModal({
|
||||
setExtraValues(provider.extraValues ?? {});
|
||||
setIdentityHeaderUserId(provider.identityHeaderUserId ?? "");
|
||||
setIdentityHeaderGroups(provider.identityHeaderGroups ?? "");
|
||||
setSkipTlsVerification(provider.skipTlsVerification ?? false);
|
||||
} else {
|
||||
const fallback = getById("openai_api");
|
||||
setProviderId("openai_api");
|
||||
@@ -340,6 +353,7 @@ export default function AIProviderModal({
|
||||
setExtraValues({});
|
||||
setIdentityHeaderUserId("");
|
||||
setIdentityHeaderGroups("");
|
||||
setSkipTlsVerification(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -390,6 +404,7 @@ export default function AIProviderModal({
|
||||
models,
|
||||
extraValues: sanitizedExtraValues,
|
||||
...identityOverrides,
|
||||
skipTlsVerification: isCustomKind ? skipTlsVerification : false,
|
||||
// Only forward the API key when the user actually rotated it
|
||||
...(apiKey && apiKey !== "••••••••" ? { apiKey } : {}),
|
||||
});
|
||||
@@ -404,21 +419,21 @@ export default function AIProviderModal({
|
||||
apiKey,
|
||||
extraValues: sanitizedExtraValues,
|
||||
...identityOverrides,
|
||||
skipTlsVerification: isCustomKind ? skipTlsVerification : false,
|
||||
models,
|
||||
enabled: true,
|
||||
});
|
||||
handleClose();
|
||||
};
|
||||
|
||||
// providerOptions are sorted into three groups, gateways first, so
|
||||
// the catalog Select makes the routing-layer entries (LiteLLM,
|
||||
// Portkey) prominent. SelectDropdown renders section headers
|
||||
// automatically when options carry a `group` value; the section
|
||||
// order tracks first-occurrence in the array.
|
||||
// providerOptions are sorted into three groups, first-party AI Providers
|
||||
// first, then AI Gateways, then the self-hosted / custom catch-all.
|
||||
// SelectDropdown renders section headers automatically when options carry a
|
||||
// `group` value; the section order tracks first-occurrence in the array.
|
||||
const providerOptions = useMemo(() => {
|
||||
const groupRank: Record<string, number> = {
|
||||
gateway: 0,
|
||||
provider: 1,
|
||||
provider: 0,
|
||||
gateway: 1,
|
||||
custom: 2,
|
||||
};
|
||||
const groupLabel: Record<string, string> = {
|
||||
@@ -604,6 +619,43 @@ export default function AIProviderModal({
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
{isCustomKind && (
|
||||
<FancyToggleSwitch
|
||||
value={skipTlsVerification}
|
||||
onChange={setSkipTlsVerification}
|
||||
label={
|
||||
<>
|
||||
<ShieldOffIcon size={15} />
|
||||
Skip TLS Verification
|
||||
<span onClick={(e) => e.stopPropagation()}>
|
||||
<HelpTooltip
|
||||
interactive
|
||||
content={
|
||||
<>
|
||||
Skips certificate validation on requests to this
|
||||
provider. Useful for quick testing against
|
||||
endpoints with self-signed certificates. For
|
||||
production we recommend mounting trusted
|
||||
certificates on your proxy instances instead.{" "}
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/agent-network/providers/self-signed-certificates"
|
||||
}
|
||||
target={"_blank"}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
helpText={"Disable upstream TLS certificate validation."}
|
||||
/>
|
||||
)}
|
||||
|
||||
{providerId === "vertex_ai_api" ? (
|
||||
<FormRow
|
||||
label={
|
||||
|
||||
@@ -47,6 +47,9 @@ export type APIProvider = {
|
||||
// stored here land on the wire.
|
||||
identity_header_user_id?: string;
|
||||
identity_header_groups?: string;
|
||||
// Skip TLS certificate verification on upstream requests (custom providers
|
||||
// with self-signed certs). Off by default.
|
||||
skip_tls_verification?: boolean;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -63,6 +66,7 @@ export type APIProviderRequest = {
|
||||
extra_values?: Record<string, string>;
|
||||
identity_header_user_id?: string;
|
||||
identity_header_groups?: string;
|
||||
skip_tls_verification?: boolean;
|
||||
models: APIProviderModel[];
|
||||
enabled?: boolean;
|
||||
};
|
||||
@@ -78,6 +82,8 @@ export type ProviderConnectInput = {
|
||||
extraValues?: Record<string, string>;
|
||||
identityHeaderUserId?: string;
|
||||
identityHeaderGroups?: string;
|
||||
// Skip upstream TLS verification (custom providers with self-signed certs).
|
||||
skipTlsVerification?: boolean;
|
||||
models: ProviderModel[];
|
||||
enabled?: boolean;
|
||||
};
|
||||
@@ -90,6 +96,7 @@ export type ProviderUpdateInput = {
|
||||
extraValues?: Record<string, string>;
|
||||
identityHeaderUserId?: string;
|
||||
identityHeaderGroups?: string;
|
||||
skipTlsVerification?: boolean;
|
||||
models?: ProviderModel[];
|
||||
enabled?: boolean;
|
||||
};
|
||||
@@ -151,6 +158,7 @@ function fromAPI(p: APIProvider): AIProvider {
|
||||
extraValues: p.extra_values ?? {},
|
||||
identityHeaderUserId: p.identity_header_user_id,
|
||||
identityHeaderGroups: p.identity_header_groups,
|
||||
skipTlsVerification: p.skip_tls_verification ?? false,
|
||||
status: p.enabled ? "active" : "disabled",
|
||||
models,
|
||||
allowedGroups: [],
|
||||
@@ -192,6 +200,7 @@ function toCreateRequest(input: ProviderConnectInput): APIProviderRequest {
|
||||
extra_values: input.extraValues,
|
||||
identity_header_user_id: input.identityHeaderUserId,
|
||||
identity_header_groups: input.identityHeaderGroups,
|
||||
skip_tls_verification: input.skipTlsVerification,
|
||||
models: toAPIModels(input.models),
|
||||
enabled: input.enabled,
|
||||
};
|
||||
@@ -635,6 +644,8 @@ export default function AIProvidersProvider({ children }: Readonly<Props>) {
|
||||
updates.identityHeaderUserId ?? existing.identity_header_user_id,
|
||||
identity_header_groups:
|
||||
updates.identityHeaderGroups ?? existing.identity_header_groups,
|
||||
skip_tls_verification:
|
||||
updates.skipTlsVerification ?? existing.skip_tls_verification,
|
||||
models: updates.models
|
||||
? toAPIModels(updates.models)
|
||||
: existing.models ?? [],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Code from "@components/Code";
|
||||
import { HelpTooltip } from "@components/HelpTooltip";
|
||||
import * as React from "react";
|
||||
import {
|
||||
AIAccessLogEntry,
|
||||
@@ -19,6 +20,7 @@ type Props = {
|
||||
*/
|
||||
export default function AgentAccessLogExpandedRow({ entry }: Readonly<Props>) {
|
||||
const isDeny = entry.decision === "deny";
|
||||
const hasSession = Boolean(entry.sessionId);
|
||||
const hasPrompt = Boolean(entry.prompt);
|
||||
const hasCompletion = Boolean(entry.completion);
|
||||
const hasBody = hasPrompt || hasCompletion;
|
||||
@@ -52,6 +54,21 @@ export default function AgentAccessLogExpandedRow({ entry }: Readonly<Props>) {
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{hasSession && (
|
||||
<Section
|
||||
heading={"Session ID"}
|
||||
tooltip={
|
||||
"The provider-side session this request belongs to. A single user " +
|
||||
"can make several separate calls that the provider groups under the " +
|
||||
"same session id."
|
||||
}
|
||||
>
|
||||
<Code dark small codeToCopy={entry.sessionId}>
|
||||
<Code.Line>{entry.sessionId}</Code.Line>
|
||||
</Code>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{isDeny && (
|
||||
<Section heading={"Reason"}>
|
||||
<div
|
||||
@@ -109,19 +126,22 @@ export default function AgentAccessLogExpandedRow({ entry }: Readonly<Props>) {
|
||||
|
||||
function Section({
|
||||
heading,
|
||||
tooltip,
|
||||
children,
|
||||
}: {
|
||||
heading: string;
|
||||
tooltip?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={"space-y-1.5"}>
|
||||
<div
|
||||
className={
|
||||
"text-[11px] font-medium uppercase tracking-wide text-nb-gray-400"
|
||||
"flex items-center gap-1 text-[11px] font-medium uppercase tracking-wide text-nb-gray-400"
|
||||
}
|
||||
>
|
||||
{heading}
|
||||
{tooltip && <HelpTooltip content={tooltip} />}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,14 +70,16 @@ 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"}>
|
||||
<TabsList justify={"start"} className={listClassName}>
|
||||
<TabsTrigger value={"claude-code"}>Claude Code</TabsTrigger>
|
||||
<TabsTrigger value={"claude-code-vertex"}>
|
||||
Claude Code (Vertex AI)
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"codex"}>Codex</TabsTrigger>
|
||||
<TabsTrigger value={"openai-sdk"}>OpenAI SDK</TabsTrigger>
|
||||
<TabsTrigger value={"curl"}>cURL</TabsTrigger>
|
||||
@@ -84,61 +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`,
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"claude-code-vertex"}>
|
||||
<div className={contentClassName}>
|
||||
<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 === "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>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DateRange } from "react-day-picker";
|
||||
import {
|
||||
AIAccessLogDecision,
|
||||
AIAccessLogEntry,
|
||||
AIAccessLogSession,
|
||||
AIProviderId,
|
||||
} from "@/modules/agent-network/data/mockData";
|
||||
|
||||
@@ -31,6 +32,7 @@ export type APIAgentNetworkAccessLog = {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
resolved_provider_id?: string;
|
||||
session_id?: string;
|
||||
selected_policy_id?: string;
|
||||
decision?: string;
|
||||
deny_reason?: string;
|
||||
@@ -48,6 +50,25 @@ export type APIAgentNetworkAccessLogsResponse = {
|
||||
total_records: number;
|
||||
};
|
||||
|
||||
// APIAgentNetworkAccessLogSession mirrors the api.AgentNetworkAccessLogSession
|
||||
// response — a session-grouped summary plus its nested entries.
|
||||
export type APIAgentNetworkAccessLogSession = {
|
||||
session_id?: string;
|
||||
user_id?: string;
|
||||
group_ids?: string[];
|
||||
started_at: string;
|
||||
ended_at: string;
|
||||
request_count: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_usd: number;
|
||||
providers?: string[];
|
||||
models?: string[];
|
||||
decision: string;
|
||||
entries: APIAgentNetworkAccessLog[];
|
||||
};
|
||||
|
||||
// APIAgentNetworkUsageBucket mirrors the api.AgentNetworkUsageBucket response —
|
||||
// one aggregated time bucket from /agent-network/usage/overview.
|
||||
export type APIAgentNetworkUsageBucket = {
|
||||
@@ -153,6 +174,7 @@ export function accessLogFromAgentAPI(
|
||||
serviceId: entry.service_id,
|
||||
providerId: normalizeProviderId(entry.provider),
|
||||
resolvedProviderId: entry.resolved_provider_id ?? "",
|
||||
sessionId: entry.session_id ?? "",
|
||||
timestamp: entry.timestamp,
|
||||
user: entry.user_id ?? "",
|
||||
userId: entry.user_id ?? "",
|
||||
@@ -177,3 +199,38 @@ export function accessLogFromAgentAPI(
|
||||
path: entry.path ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
// accessLogSessionFromAgentAPI maps a session-grouped API entry to the
|
||||
// dashboard's AIAccessLogSession, reusing accessLogFromAgentAPI for the nested
|
||||
// entries. The row id mirrors the backend group key (session id, or the
|
||||
// singleton request's id when there is no session id).
|
||||
export function accessLogSessionFromAgentAPI(
|
||||
session: APIAgentNetworkAccessLogSession,
|
||||
groupNamesByID: Map<string, string>,
|
||||
): AIAccessLogSession {
|
||||
const entries = (session.entries ?? []).map((e) =>
|
||||
accessLogFromAgentAPI(e, groupNamesByID),
|
||||
);
|
||||
const sessionId = session.session_id ?? "";
|
||||
const decision: AIAccessLogDecision =
|
||||
session.decision === "deny" ? "deny" : "allow";
|
||||
|
||||
return {
|
||||
id: sessionId || entries[0]?.id || "",
|
||||
sessionId,
|
||||
user: session.user_id ?? "",
|
||||
userId: session.user_id ?? "",
|
||||
userGroups: resolveGroupNames(session.group_ids, groupNamesByID),
|
||||
startedAt: session.started_at,
|
||||
endedAt: session.ended_at,
|
||||
requestCount: session.request_count ?? entries.length,
|
||||
inputTokens: session.input_tokens ?? 0,
|
||||
outputTokens: session.output_tokens ?? 0,
|
||||
totalTokens: session.total_tokens ?? 0,
|
||||
costUsd: session.cost_usd ?? 0,
|
||||
providers: session.providers ?? [],
|
||||
models: session.models ?? [],
|
||||
decision,
|
||||
entries,
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export type AIProviderId =
|
||||
| "cloudflare_ai_gateway"
|
||||
| "vercel_ai_gateway"
|
||||
| "openrouter"
|
||||
| "vllm"
|
||||
| "custom";
|
||||
|
||||
export type AIProviderStatus = "active" | "warning" | "disabled";
|
||||
@@ -60,6 +61,9 @@ export type AIProvider = {
|
||||
// the value stored here lands on the wire.
|
||||
identityHeaderUserId?: string;
|
||||
identityHeaderGroups?: string;
|
||||
// Skip TLS certificate verification on upstream requests — for custom
|
||||
// providers using self-signed certs. Off by default.
|
||||
skipTlsVerification?: boolean;
|
||||
status: AIProviderStatus;
|
||||
models: ProviderModel[];
|
||||
allowedGroups: string[];
|
||||
@@ -154,6 +158,10 @@ export type AIAccessLogEntry = {
|
||||
// (llm.resolved_provider_id metadata). Empty for legacy entries
|
||||
// and non-agent-network requests where the router didn't run.
|
||||
resolvedProviderId: string;
|
||||
// Provider-side session id grouping related calls. A single user can make
|
||||
// several separate requests that the provider ties to one session
|
||||
// (llm.session_id metadata). Empty when the provider didn't return one.
|
||||
sessionId: string;
|
||||
timestamp: string;
|
||||
user: string;
|
||||
userId: string;
|
||||
@@ -184,6 +192,30 @@ export type AIAccessLogEntry = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
// AIAccessLogSession is a session-grouped view of access-log entries: all
|
||||
// requests sharing a provider session id (or a single session-less request,
|
||||
// keyed by its own id) folded into one summary plus its ordered entries.
|
||||
export type AIAccessLogSession = {
|
||||
// Stable row id for table expansion: the session id, or the singleton
|
||||
// request's id when the session id is empty. Mirrors the backend group key.
|
||||
id: string;
|
||||
sessionId: string; // empty for a session-less (singleton) request
|
||||
user: string;
|
||||
userId: string;
|
||||
userGroups: string[];
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
requestCount: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
costUsd: number;
|
||||
providers: string[]; // distinct vendor ids seen in the session
|
||||
models: string[]; // distinct models seen in the session
|
||||
decision: AIAccessLogDecision;
|
||||
entries: AIAccessLogEntry[];
|
||||
};
|
||||
|
||||
// Short labels for the proxy's llm_policy.reason deny codes. Keyed by the bare
|
||||
// reason (the prefix is stripped before lookup) so both forms the proxy emits
|
||||
// resolve — bare ("model_not_routable") and prefixed deny codes
|
||||
|
||||
@@ -541,9 +541,13 @@ export default function PeersTable({
|
||||
<ButtonGroup.Button
|
||||
className={"h-[42px]"}
|
||||
variant={kind === "users" ? "tertiary" : "secondary"}
|
||||
onClick={() =>
|
||||
onKindChange?.(kind === "users" ? undefined : "users")
|
||||
}
|
||||
onClick={() => {
|
||||
// Reset to the first page: the new subset may have fewer
|
||||
// pages than the current index, which would show an empty
|
||||
// table (page index doesn't auto-reset on data change).
|
||||
table.setPageIndex(0);
|
||||
onKindChange?.(kind === "users" ? undefined : "users");
|
||||
}}
|
||||
>
|
||||
User Devices
|
||||
</ButtonGroup.Button>
|
||||
@@ -552,9 +556,10 @@ export default function PeersTable({
|
||||
// button's right border into a doubled divider.
|
||||
className={"h-[42px] !border-l-0"}
|
||||
variant={kind === "servers" ? "tertiary" : "secondary"}
|
||||
onClick={() =>
|
||||
onKindChange?.(kind === "servers" ? undefined : "servers")
|
||||
}
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
onKindChange?.(kind === "servers" ? undefined : "servers");
|
||||
}}
|
||||
>
|
||||
Servers
|
||||
</ButtonGroup.Button>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { notify } from "@components/Notification";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import {
|
||||
ChartNoAxesCombined,
|
||||
ExternalLinkIcon,
|
||||
} from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import SettingsIcon from "@/assets/icons/SettingsIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Account } from "@/interfaces/Account";
|
||||
|
||||
type Props = {
|
||||
account: Account;
|
||||
};
|
||||
|
||||
export default function MetricsTab({ account }: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
const { mutate } = useSWRConfig();
|
||||
const saveRequest = useApiCall<Account>("/accounts/" + account.id, true);
|
||||
|
||||
const [metricsPushEnabled, setMetricsPushEnabled] = useState(
|
||||
account.settings?.metrics_push_enabled ?? false,
|
||||
);
|
||||
|
||||
const toggleMetricsPush = async (toggle: boolean) => {
|
||||
notify({
|
||||
title: "Metrics",
|
||||
description: `Metrics push successfully ${
|
||||
toggle ? "enabled" : "disabled"
|
||||
}.`,
|
||||
promise: saveRequest
|
||||
.put({
|
||||
id: account.id,
|
||||
settings: {
|
||||
...account.settings,
|
||||
metrics_push_enabled: toggle,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
setMetricsPushEnabled(toggle);
|
||||
mutate("/accounts");
|
||||
}),
|
||||
loadingMessage: "Updating metrics setting...",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs.Content value={"metrics"}>
|
||||
<div className={"p-default py-6 max-w-2xl"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/settings"}
|
||||
label={"Settings"}
|
||||
icon={<SettingsIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/settings?tab=metrics"}
|
||||
label={"Metrics"}
|
||||
icon={<ChartNoAxesCombined size={14} />}
|
||||
active
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<div>
|
||||
<h1>Metrics</h1>
|
||||
<Paragraph>
|
||||
Help us improve NetBird by sharing performance metrics
|
||||
such as connection timing, sync duration, and login latency.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about{" "}
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/manage/client-metrics"
|
||||
}
|
||||
target={"_blank"}
|
||||
>
|
||||
Client Metrics
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<FancyToggleSwitch
|
||||
className={"mt-6"}
|
||||
value={metricsPushEnabled}
|
||||
onChange={toggleMetricsPush}
|
||||
label={
|
||||
<>
|
||||
<ChartNoAxesCombined size={15} />
|
||||
Share performance metrics
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
"When enabled, clients will periodically send performance data to help us identify and fix issues."
|
||||
}
|
||||
disabled={!permission.settings.update}
|
||||
/>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
);
|
||||
}
|
||||
+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