feat(ui): show scope and assignments from both directions

A destination's Scope column on the Logging Callbacks table now unions both
routing sources -- credential_info.access on the credential AND every team/org
whose metadata.logging_exporters lists this destination -- and renders the
matched teams/orgs by alias rather than a count. Mirrors the same union on the
team, key, and organization views: the Logging Exporters section shows the
identity's own assignments plus destinations whose access targets it (labeled
"via scope"). Same union the resolver applies at request time, just visible.
This commit is contained in:
yucheng-berriai 2026-06-22 15:30:20 -07:00
parent a90eff3d5b
commit e0658b821b
7 changed files with 250 additions and 60 deletions

View file

@ -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 <span className="text-gray-400">—</span>;
}
if (scope.global) {
return <Tag color="blue">Global</Tag>;
}
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 (
<div className="flex flex-wrap gap-1">
{shown.map((item, i) => (
<Tag key={`${item.kind}-${item.label}-${i}`} color={item.kind === "team" ? "blue" : "geekblue"}>
{item.kind}: {item.label}
</Tag>
))}
{remainder > 0 && <Tag>+{remainder} more</Tag>}
</div>
);
};
type CallbackRow = AlertingObject & {
@ -102,12 +123,8 @@ export const LoggingCallbacksTable: React.FC<LoggingCallbacksProps> = ({
title: <span className="font-medium text-gray-700">Scope</span>,
key: "access",
render: (_: unknown, record: CallbackRow) =>
isDestination(record) ? (
<span className="text-sm text-gray-700">{accessSummary(record)}</span>
) : (
<span className="text-gray-400">—</span>
),
width: 160,
isDestination(record) ? <ScopeCell record={record} /> : <span className="text-gray-400">—</span>,
width: 280,
},
{
title: <span className="font-medium text-gray-700 text-right w-full block">Actions</span>,

View file

@ -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;

View file

@ -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 = (
<div className="space-y-6">
{/* 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. */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<CogIcon className="h-4 w-4 text-blue-600" />
<span className="font-semibold text-gray-900">Logging Exporters</span>
<Tag color="blue">{loggingExporters.length}</Tag>
</div>
{loggingExporters.length > 0 ? (
<div className="flex flex-wrap gap-2">
{loggingExporters.map((name, index) => (
<Tag key={index} color="blue">
{name}
</Tag>
))}
</div>
) : (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 border border-gray-200">
<CogIcon className="h-4 w-4 text-gray-400" />
<span className="text-gray-500 text-sm">No logging exporters assigned</span>
</div>
)}
{(() => {
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 (
<>
<div className="flex items-center gap-2">
<CogIcon className="h-4 w-4 text-blue-600" />
<span className="font-semibold text-gray-900">Logging Exporters</span>
<Tag color="blue">{entries.length}</Tag>
</div>
{entries.length > 0 ? (
<div className="flex flex-wrap gap-2">
{entries.map((entry, index) => (
<Tag key={index} color={entry.source === "own" ? "blue" : "geekblue"}>
{entry.name}
{entry.source === "scope" ? " (via scope)" : ""}
</Tag>
))}
</div>
) : (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 border border-gray-200">
<CogIcon className="h-4 w-4 text-gray-400" />
<span className="text-gray-500 text-sm">No logging exporters assigned</span>
</div>
)}
</>
);
})()}
</div>
{/* Logging Integrations Section */}

View file

@ -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<OrganizationInfoProps> = ({
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<string[]>(() => {
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<OrganizationInfoProps> = ({
<Card>
<Text>Logging Exporters</Text>
<div className="mt-2 flex flex-wrap gap-2">
{Array.isArray(orgData.metadata?.logging_exporters) &&
orgData.metadata.logging_exporters.length > 0 ? (
orgData.metadata.logging_exporters.map((name: string, index: number) => (
<Badge key={index} color="blue">
{name}
</Badge>
))
) : (
<Text className="text-gray-400">None</Text>
)}
{(() => {
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) => (
<Badge key={i} color={e.source === "own" ? "blue" : "indigo"}>
{e.n}
{e.source === "scope" ? " (via scope)" : ""}
</Badge>
))
) : (
<Text className="text-gray-400">None</Text>
);
})()}
</div>
</Card>
@ -523,18 +554,29 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
</div>
<div>
<Text className="font-medium">Logging Exporters</Text>
{Array.isArray(orgData.metadata?.logging_exporters) &&
orgData.metadata.logging_exporters.length > 0 ? (
<div className="flex flex-wrap gap-2 mt-1">
{orgData.metadata.logging_exporters.map((name: string, index: number) => (
<Badge key={index} color="blue">
{name}
</Badge>
))}
</div>
) : (
<div className="text-gray-400 mt-1">None</div>
)}
{(() => {
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 ? (
<div className="flex flex-wrap gap-2 mt-1">
{all.map((e, i) => (
<Badge key={i} color={e.source === "own" ? "blue" : "indigo"}>
{e.n}
{e.source === "scope" ? " (via scope)" : ""}
</Badge>
))}
</div>
) : (
<div className="text-gray-400 mt-1">None</div>
);
})()}
</div>
<ObjectPermissionsView

View file

@ -39,8 +39,14 @@ import {
setCallbacksCall,
} from "./networking";
import { LoggingCallbacksTable } from "./Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable";
import { AlertingObject, CredentialAccess } from "./Settings/LoggingAndAlerts/LoggingCallbacks/types";
import {
AlertingObject,
CredentialAccess,
ResolvedScope,
} from "./Settings/LoggingAndAlerts/LoggingCallbacks/types";
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import EditLoggingCredentialModal from "./logging_credentials/EditLoggingCredentialModal";
import AccessControlFields from "./logging_credentials/AccessControlFields";
import {
@ -261,6 +267,8 @@ const Settings: React.FC<SettingsPageProps> = ({ 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<CredentialAccess>({});
@ -268,6 +276,41 @@ const Settings: React.FC<SettingsPageProps> = ({ 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<string>();
const orgs = new Set<string>();
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<string, unknown> | 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<string, unknown> | 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<SettingsPageProps> = ({ 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) => {

View file

@ -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<TeamInfoProps> = ({
}, [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<string[]>(() => {
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<TeamInfoProps> = ({
loggingExporters={
Array.isArray(info.metadata?.logging_exporters) ? info.metadata.logging_exporters : []
}
scopedExporters={scopedExportersForTeam}
disabledCallbacks={[]}
variant="card"
/>
@ -1658,6 +1679,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
loggingExporters={
Array.isArray(info.metadata?.logging_exporters) ? info.metadata.logging_exporters : []
}
scopedExporters={scopedExportersForTeam}
disabledCallbacks={[]}
variant="inline"
className="pt-4 border-t border-gray-200"

View file

@ -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<KeyResponse | undefined>(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<string[]>(() => {
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<Date | null>(null);
const [isRecentlyRegenerated, setIsRecentlyRegenerated] = useState(false);
const [policyGuardrails, setPolicyGuardrails] = useState<Record<string, string[]>>({});
@ -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({
<LoggingSettingsView
loggingConfigs={extractLoggingSettings(currentKeyData.metadata)}
loggingExporters={
Array.isArray(currentKeyData.metadata?.logging_exporters)
? currentKeyData.metadata.logging_exporters
: []
}
scopedExporters={scopedExportersForKey}
disabledCallbacks={
Array.isArray(currentKeyData.metadata?.litellm_disabled_callbacks)
? mapInternalToDisplayNames(currentKeyData.metadata.litellm_disabled_callbacks)