diff --git a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx index 2c66ed3f5f3..82555ce8929 100644 --- a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx +++ b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx @@ -1,6 +1,6 @@ import { Button } from "@tremor/react"; import type { TableProps } from "antd"; -import { Table } from "antd"; +import { Table, Tag } from "antd"; import Title from "antd/es/typography/Title"; import React from "react"; import TableIconActionButton from "../../../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; @@ -25,15 +25,36 @@ type LoggingCallbacksProps = { const isDestination = (record: AlertingObject): boolean => record.credentialName != null; -const accessSummary = (record: AlertingObject): string => { - const access = record.access; - if (!access) return "—"; - if (access.global) return "Global"; - const parts = [ - access.teams?.length ? `${access.teams.length} team${access.teams.length > 1 ? "s" : ""}` : null, - access.orgs?.length ? `${access.orgs.length} org${access.orgs.length > 1 ? "s" : ""}` : null, - ].filter(Boolean); - return parts.length ? parts.join(" · ") : "—"; +const SCOPE_BADGES_LIMIT = 4; + +// Renders the union of identities that route to this destination, with each team/org +// labeled by its alias. Global supersedes everything. Pulls from record.resolvedScope +// (computed at the page level from BOTH directions: destination-side access AND +// identity-side metadata.logging_exporters). +const ScopeCell: React.FC<{ record: AlertingObject }> = ({ record }) => { + const scope = record.resolvedScope; + if (!scope || (!scope.global && scope.teams.length === 0 && scope.orgs.length === 0)) { + return —; + } + if (scope.global) { + return Global; + } + const items = [ + ...scope.teams.map((label) => ({ kind: "team" as const, label })), + ...scope.orgs.map((label) => ({ kind: "org" as const, label })), + ]; + const shown = items.slice(0, SCOPE_BADGES_LIMIT); + const remainder = items.length - shown.length; + return ( +
+ {shown.map((item, i) => ( + + {item.kind}: {item.label} + + ))} + {remainder > 0 && +{remainder} more} +
+ ); }; type CallbackRow = AlertingObject & { @@ -102,12 +123,8 @@ export const LoggingCallbacksTable: React.FC = ({ title: Scope, key: "access", render: (_: unknown, record: CallbackRow) => - isDestination(record) ? ( - {accessSummary(record)} - ) : ( - — - ), - width: 160, + isDestination(record) ? : —, + width: 280, }, { title: Actions, diff --git a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/types.ts b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/types.ts index 42fdf5d3de7..3bf7b09e47c 100644 --- a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/types.ts +++ b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/types.ts @@ -14,6 +14,11 @@ export interface AlertingObject { credentialName?: string; destinationLabel?: string; access?: CredentialAccess; + // The union of identities that route to this destination, resolved at render + // time from both directions (destination-side credential_info.access AND + // identity-side metadata.logging_exporters). Display labels only -- ids are + // not surfaced here. global=true bypasses the lists. + resolvedScope?: ResolvedScope; } export interface CredentialAccess { @@ -22,6 +27,12 @@ export interface CredentialAccess { orgs?: string[]; } +export interface ResolvedScope { + global: boolean; + teams: string[]; + orgs: string[]; +} + export interface AlertingVariables { SLACK_WEBHOOK_URL: string | null; LANGFUSE_PUBLIC_KEY: string | null; diff --git a/ui/litellm-dashboard/src/components/logging_settings_view.tsx b/ui/litellm-dashboard/src/components/logging_settings_view.tsx index ec8810f93b5..2a2fa9bbf9d 100644 --- a/ui/litellm-dashboard/src/components/logging_settings_view.tsx +++ b/ui/litellm-dashboard/src/components/logging_settings_view.tsx @@ -12,7 +12,12 @@ interface LoggingConfig { interface LoggingSettingsViewProps { loggingConfigs?: LoggingConfig[]; disabledCallbacks?: string[]; + // Destinations this identity assigned itself, via metadata.logging_exporters. loggingExporters?: string[]; + // Destinations that target this identity via the credential's own scope + // (credential_info.access.{teams,orgs,global}) -- the other direction. The + // resolver unions both at request time; the UI unions them here for display. + scopedExporters?: string[]; variant?: "card" | "inline"; className?: string; } @@ -21,6 +26,7 @@ export function LoggingSettingsView({ loggingConfigs = [], disabledCallbacks = [], loggingExporters = [], + scopedExporters = [], variant = "card", className = "", }: LoggingSettingsViewProps) { @@ -58,27 +64,44 @@ export function LoggingSettingsView({ const content = (
- {/* Logging Exporters (admin-owned OTEL trace destinations assigned to this identity) */} + {/* Logging Exporters: the union of destinations routing to this identity. + Own = destinations this identity listed in its metadata.logging_exporters. + Via scope = destinations whose credential_info.access targets this identity + (a team/org id, or global). Both directions count; we render them together, + marking how each entry was resolved. */}
-
- - Logging Exporters - {loggingExporters.length} -
- {loggingExporters.length > 0 ? ( -
- {loggingExporters.map((name, index) => ( - - {name} - - ))} -
- ) : ( -
- - No logging exporters assigned -
- )} + {(() => { + const ownSet = new Set(loggingExporters); + const scopedOnly = scopedExporters.filter((name) => !ownSet.has(name)); + const entries = [ + ...loggingExporters.map((name) => ({ name, source: "own" as const })), + ...scopedOnly.map((name) => ({ name, source: "scope" as const })), + ]; + return ( + <> +
+ + Logging Exporters + {entries.length} +
+ {entries.length > 0 ? ( +
+ {entries.map((entry, index) => ( + + {entry.name} + {entry.source === "scope" ? " (via scope)" : ""} + + ))} +
+ ) : ( +
+ + No logging exporters assigned +
+ )} + + ); + })()}
{/* Logging Integrations Section */} diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.tsx index 664f0bb5fa1..a02acbe07da 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.tsx @@ -13,6 +13,7 @@ import MemberTable from "../common_components/MemberTable"; import UserSearchModal from "../common_components/user_search_modal"; import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; import LoggingExportersSelect from "../logging_credentials/LoggingExportersSelect"; +import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials"; import { ModelSelect } from "../ModelSelect/ModelSelect"; import NotificationsManager from "../molecules/notifications_manager"; import { @@ -61,6 +62,25 @@ const OrganizationInfoView: React.FC = ({ const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]); + // Destinations whose credential_info.access targets THIS org (or is global). + // Rendered alongside the org's own metadata.logging_exporters so the Logging + // Exporters card reflects BOTH routing directions, matching the resolver's + // union at request time. + const { data: orgCredentialsData } = useCredentials(); + const scopedExportersForOrg = useMemo(() => { + const orgId = orgData?.organization_id; + if (orgId == null) return []; + return (orgCredentialsData?.credentials ?? []) + .filter((c) => c.credential_info?.credential_type === "logging") + .filter((c) => { + const access = c.credential_info?.access; + if (!access) return false; + if (access.global === true) return true; + return Array.isArray(access.orgs) && access.orgs.includes(orgId); + }) + .map((c) => c.credential_name); + }, [orgCredentialsData?.credentials, orgData?.organization_id]); + const handleMemberAdd = async (values: any) => { try { if (accessToken == null) { @@ -317,16 +337,27 @@ const OrganizationInfoView: React.FC = ({ Logging Exporters
- {Array.isArray(orgData.metadata?.logging_exporters) && - orgData.metadata.logging_exporters.length > 0 ? ( - orgData.metadata.logging_exporters.map((name: string, index: number) => ( - - {name} - - )) - ) : ( - None - )} + {(() => { + const own = Array.isArray(orgData.metadata?.logging_exporters) + ? (orgData.metadata.logging_exporters as string[]) + : []; + const ownSet = new Set(own); + const scopedOnly = scopedExportersForOrg.filter((n) => !ownSet.has(n)); + const all = [ + ...own.map((n) => ({ n, source: "own" as const })), + ...scopedOnly.map((n) => ({ n, source: "scope" as const })), + ]; + return all.length > 0 ? ( + all.map((e, i) => ( + + {e.n} + {e.source === "scope" ? " (via scope)" : ""} + + )) + ) : ( + None + ); + })()}
@@ -523,18 +554,29 @@ const OrganizationInfoView: React.FC = ({
Logging Exporters - {Array.isArray(orgData.metadata?.logging_exporters) && - orgData.metadata.logging_exporters.length > 0 ? ( -
- {orgData.metadata.logging_exporters.map((name: string, index: number) => ( - - {name} - - ))} -
- ) : ( -
None
- )} + {(() => { + const own = Array.isArray(orgData.metadata?.logging_exporters) + ? (orgData.metadata.logging_exporters as string[]) + : []; + const ownSet = new Set(own); + const scopedOnly = scopedExportersForOrg.filter((n) => !ownSet.has(n)); + const all = [ + ...own.map((n) => ({ n, source: "own" as const })), + ...scopedOnly.map((n) => ({ n, source: "scope" as const })), + ]; + return all.length > 0 ? ( +
+ {all.map((e, i) => ( + + {e.n} + {e.source === "scope" ? " (via scope)" : ""} + + ))} +
+ ) : ( +
None
+ ); + })()}
= ({ accessToken, userRole, userID, // OTEL trace destinations are credentials tagged credential_type=logging; they share // the one Active Logging Callbacks table as rows alongside config callbacks. const { data: credentialData, refetch: refetchCredentials } = useCredentials(); + const { data: teamsData } = useTeams(); + const { data: orgsData } = useOrganizations(); const [editAccessFor, setEditAccessFor] = useState<{ name: string; access?: CredentialAccess } | null>(null); // access for the destination branch of the unified Add modal const [addAccess, setAddAccess] = useState({}); @@ -268,6 +276,41 @@ const Settings: React.FC = ({ accessToken, userRole, userID, const addingDestinationFields = LOGGING_DESTINATION_BACKENDS.find((b) => b.id === selectedCallback)?.fields ?? []; + const teamAlias = (id: string): string => { + const t = (teamsData ?? []).find((team) => team.team_id === id); + return t?.team_alias || id; + }; + const orgAlias = (id: string): string => { + const o = (orgsData ?? []).find((org) => org.organization_id === id); + return o?.organization_alias || id; + }; + + // For each destination, the Scope column reflects BOTH directions: + // (a) destination-side credential_info.access (global/teams/orgs on the credential) + // (b) identity-side metadata.logging_exporters on each team/org that lists this destination + // The resolver unions them at request time; the column unions them at render time. + const resolveScope = (destinationName: string, access?: CredentialAccess): ResolvedScope => { + const teams = new Set(); + const orgs = new Set(); + let global = access?.global === true; + for (const teamId of access?.teams ?? []) teams.add(teamAlias(teamId)); + for (const orgId of access?.orgs ?? []) orgs.add(orgAlias(orgId)); + for (const team of teamsData ?? []) { + const teamMetadata = (team as { metadata?: Record | null }).metadata; + const exporters = teamMetadata?.logging_exporters; + if (Array.isArray(exporters) && exporters.includes(destinationName)) { + teams.add(team.team_alias || team.team_id); + } + } + for (const org of orgsData ?? []) { + const exporters = (org.metadata as Record | null | undefined)?.logging_exporters; + if (Array.isArray(exporters) && exporters.includes(destinationName)) { + orgs.add(org.organization_alias || org.organization_id); + } + } + return { global, teams: Array.from(teams), orgs: Array.from(orgs) }; + }; + const destinationRows: AlertingObject[] = (credentialData?.credentials ?? []) .filter((c) => c.credential_info?.credential_type === "logging") .map((c) => ({ @@ -278,6 +321,7 @@ const Settings: React.FC = ({ accessToken, userRole, userID, ? `${backendLabel(c.credential_info?.description)} · ${c.credential_info.host}` : backendLabel(c.credential_info?.description), access: c.credential_info?.access, + resolvedScope: resolveScope(c.credential_name, c.credential_info?.access), })); const handleDeleteDestination = async (name: string) => { diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 196d6242e44..c805cbf4c17 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -41,6 +41,7 @@ import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSel import { unfurlWildcardModelsInList } from "../key_team_helpers/fetch_available_models_team_key"; import GuardrailSettingsView from "../GuardrailSettingsView"; import LoggingSettingsView from "../logging_settings_view"; +import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials"; import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; import MCPToolPermissions from "../mcp_server_management/MCPToolPermissions"; import { ModelSelect } from "../ModelSelect/ModelSelect"; @@ -234,6 +235,25 @@ const TeamInfoView: React.FC = ({ }, [selectedModelsInForm, teamData, userModels]); const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam; + + // Destinations whose credential_info.access targets this team (or its org, or + // global). Rendered alongside the team's own metadata.logging_exporters so the + // Logging Exporters card reflects BOTH routing directions, matching the + // resolver's union at request time. + const { data: scopedCredentialsData } = useCredentials(); + const scopedExportersForTeam = useMemo(() => { + const orgId = teamData?.team_info?.organization_id ?? null; + return (scopedCredentialsData?.credentials ?? []) + .filter((c) => c.credential_info?.credential_type === "logging") + .filter((c) => { + const access = c.credential_info?.access; + if (!access) return false; + if (access.global === true) return true; + if (Array.isArray(access.teams) && access.teams.includes(teamId)) return true; + return Array.isArray(access.orgs) && orgId != null && access.orgs.includes(orgId); + }) + .map((c) => c.credential_name); + }, [scopedCredentialsData?.credentials, teamId, teamData?.team_info?.organization_id]); const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]); const defaultTabKey = useMemo(() => getTeamInfoDefaultTab(editTeam, canEditTeam), [editTeam, canEditTeam]); @@ -869,6 +889,7 @@ const TeamInfoView: React.FC = ({ loggingExporters={ Array.isArray(info.metadata?.logging_exporters) ? info.metadata.logging_exporters : [] } + scopedExporters={scopedExportersForTeam} disabledCallbacks={[]} variant="card" /> @@ -1658,6 +1679,7 @@ const TeamInfoView: React.FC = ({ loggingExporters={ Array.isArray(info.metadata?.logging_exporters) ? info.metadata.logging_exporters : [] } + scopedExporters={scopedExportersForTeam} disabledCallbacks={[]} variant="inline" className="pt-4 border-t border-gray-200" diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index 2590ac99c65..c3d080b5ce1 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -8,7 +8,7 @@ import { ArrowLeftIcon } from "@heroicons/react/outline"; import { Badge, Button, Card, Grid, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react"; import { Form, Modal, Tag } from "antd"; import { KeyInfoHeader } from "./KeyInfoHeader"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { isProxyAdminRole, isUserTeamAdminForSingleTeam, rolesWithWriteAccess } from "../../utils/roles"; import { mapDisplayToInternalNames, mapInternalToDisplayNames } from "../callback_info_helpers"; import AutoRotationView from "../common_components/AutoRotationView"; @@ -16,6 +16,8 @@ import DeleteResourceModal from "../common_components/DeleteResourceModal"; import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata } from "../key_info_utils"; import { KeyResponse } from "../key_team_helpers/key_list"; import LoggingSettingsView from "../logging_settings_view"; +import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials"; +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import NotificationManager from "../molecules/notifications_manager"; import { getPolicyInfoWithGuardrails, keyDeleteCall, keyUpdateCall } from "../networking"; import { useResetKeySpend } from "@/app/(dashboard)/hooks/keys/useResetKeySpend"; @@ -66,6 +68,8 @@ export default function KeyInfoView({ const { accessToken, userId: userID, userRole, premiumUser } = useAuthorized(); const canEditGuardrails = premiumUser || (userRole != null && rolesWithWriteAccess.includes(userRole)); const { teams: teamsData } = useTeams(); + const { data: keyCredentialsData } = useCredentials(); + const { data: keyOrganizationsData } = useOrganizations(); const { data: projects } = useProjects(); const { data: uiSettingsData } = useUISettings(); const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui); @@ -79,6 +83,26 @@ export default function KeyInfoView({ const { mutate: resetKeySpend, isPending: resetSpendLoading } = useResetKeySpend(); // Add local state to maintain key data and track regeneration const [currentKeyData, setCurrentKeyData] = useState(keyData); + + // Destinations whose credential_info.access targets THIS key (via its team_id, + // its team's organization_id, or global). Rendered alongside the key's own + // metadata.logging_exporters so the Logging Exporters section reflects BOTH + // routing directions, matching the resolver's union at request time. + const scopedExportersForKey = useMemo(() => { + const keyTeamId = (currentKeyData as { team_id?: string | null } | undefined)?.team_id ?? null; + const team = (teamsData ?? []).find((t) => t.team_id === keyTeamId); + const teamOrgId = (team as { organization_id?: string | null } | undefined)?.organization_id ?? null; + return (keyCredentialsData?.credentials ?? []) + .filter((c) => c.credential_info?.credential_type === "logging") + .filter((c) => { + const access = c.credential_info?.access; + if (!access) return false; + if (access.global === true) return true; + if (Array.isArray(access.teams) && keyTeamId && access.teams.includes(keyTeamId)) return true; + return Array.isArray(access.orgs) && teamOrgId != null && access.orgs.includes(teamOrgId); + }) + .map((c) => c.credential_name); + }, [keyCredentialsData?.credentials, currentKeyData, teamsData, keyOrganizationsData]); const [lastRegeneratedAt, setLastRegeneratedAt] = useState(null); const [isRecentlyRegenerated, setIsRecentlyRegenerated] = useState(false); const [policyGuardrails, setPolicyGuardrails] = useState>({}); @@ -624,6 +648,7 @@ export default function KeyInfoView({ ? currentKeyData.metadata.logging_exporters : [] } + scopedExporters={scopedExportersForKey} disabledCallbacks={ Array.isArray(currentKeyData.metadata?.litellm_disabled_callbacks) ? mapInternalToDisplayNames(currentKeyData.metadata.litellm_disabled_callbacks) @@ -872,6 +897,12 @@ export default function KeyInfoView({