Compare commits

...
8 Commits
Author SHA1 Message Date
Misha Bragin 30dd75d95d Feature/add skip tls verification (#697) 2026-07-01 22:25:05 +02:00
Zoltan Papp bfa064a19d Add Metrics settings tab with metrics push toggle (#613)
* Add Metrics settings tab with metrics push toggle

Add a dedicated Metrics tab to Settings with a toggle for enabling
anonymous performance metrics collection. Moves metrics out of the
Clients tab into its own tab with description and docs link.

* Update docs link
2026-07-01 19:00:00 +02:00
Misha Bragin db73d18370 Feature/add provider session (#695) 2026-07-01 16:32:44 +02:00
Misha Bragin ba32178425 Fix peer table page reset when switching kind (#696) 2026-07-01 16:09:15 +02:00
4ea4fd4a4c Reload nginx after patching CSP header (#687)
* fix(docker): reload nginx after patching CSP header

nginx is started by supervisord (priority 100) before init_react_envs.sh
(priority 201) and is never reloaded afterwards, so it keeps serving the
static default.conf CSP that has no connect-src. Over plain HTTP this blocks
OIDC discovery and the management API, breaking browser SSH/RDP auth.

Reload nginx at the end of the init script so the dynamically built CSP
header takes effect.

* Update docker/init_react_envs.sh

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix(docker): log nginx -t output on failed CSP reload

When the reload never succeeds, run nginx -t before exiting so the logs show
whether the patched config is invalid or nginx never came up.

* conditionally emit `upgrade-insecure-requests` in CSP

---------

Co-authored-by: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: mlsmaycon <mlsmaycon@gmail.com>
2026-07-01 15:32:22 +02:00
Bethuel Mmbaga deaae9159c Show reason for peer login expiration events (#694) 2026-07-01 12:32:11 +03:00
Bethuel Mmbaga d7fd24244d Prevent gated useFetchApi hooks from overwriting shared cache entries (#692) 2026-06-30 17:44:38 +03:00
Misha Bragin 9230b3e4e3 Add Claude provider selector and Bedrock (#691) 2026-06-30 11:50:15 +02:00
16 changed files with 947 additions and 94 deletions

No files matched your search

+33 -2
View File
@@ -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>
+7
View File
@@ -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>
+1
View File
@@ -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[];
+11 -6
View File
@@ -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 (
+59 -7
View File
@@ -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
+92 -55
View File
@@ -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
+11 -6
View File
@@ -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>
+108
View File
@@ -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
View File
@@ -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,