mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
feat(ui): manage admin-owned logging destinations
Adds the destinations table, the scoped-destination add flow, the Edit scope dialog and the Not active badge to Logging and Alerts. Squashed onto the rebased disclosure branch; the previous history conflicted with staging's changes to the same dashboard files.
This commit is contained in:
parent
b728bba7e1
commit
d5580b7a51
26 changed files with 1378 additions and 100 deletions
|
|
@ -3135,6 +3135,16 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/logging_credentials/AccessControlFields.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/logging_credentials/EditLoggingCredentialModal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/logging_settings_view.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
@ -3555,6 +3565,9 @@
|
|||
"local/no-complex-jsx-arrow": {
|
||||
"count": 4
|
||||
},
|
||||
"max-lines": {
|
||||
"count": 1
|
||||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
},
|
||||
|
|
@ -4310,4 +4323,4 @@
|
|||
"count": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
|||
|
||||
const credentialsKeys = createQueryKeys("credentials");
|
||||
|
||||
export const useCredentials = () => {
|
||||
export const useCredentials = (enabled: boolean = true) => {
|
||||
const { accessToken } = useAuthorized();
|
||||
return useQuery<CredentialsResponse>({
|
||||
queryKey: credentialsKeys.list({}),
|
||||
queryFn: async () => await credentialListCall(accessToken!),
|
||||
enabled: Boolean(accessToken),
|
||||
enabled: enabled && Boolean(accessToken),
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
|
|
@ -97,12 +97,6 @@ describe("LoggingCallbacksTable", () => {
|
|||
expect(onDelete).toHaveBeenCalledWith(callback);
|
||||
});
|
||||
|
||||
// Regression: `/get_callbacks` returns the same `name` twice when a
|
||||
// callback is registered for both success and failure (e.g. `generic_api`
|
||||
// → POST to spend-log on both 200 and 4xx/5xx). The UI used to ignore
|
||||
// the `type` field and render every row as "Success", masking the
|
||||
// failure registration. Reading `record.type` fixes the badge AND
|
||||
// composing the row id with type avoids React's duplicate-key warning.
|
||||
it("renders distinct Success and Failure badges for same-name dual registration", () => {
|
||||
render(
|
||||
<LoggingCallbacksTable
|
||||
|
|
@ -123,4 +117,216 @@ describe("LoggingCallbacksTable", () => {
|
|||
expect(screen.getByText("Success")).toBeInTheDocument();
|
||||
expect(screen.getByText("Failure")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a global destination's scope", () => {
|
||||
render(
|
||||
<LoggingCallbacksTable
|
||||
callbacks={[
|
||||
{
|
||||
name: "langfuse-eu",
|
||||
variables: baseVars,
|
||||
credentialName: "langfuse-eu",
|
||||
access: { global: true },
|
||||
resolvedScope: { global: true, teams: [], orgs: [] },
|
||||
},
|
||||
]}
|
||||
availableCallbacks={{}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Global access")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Success")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a scoped destination's resolved teams and orgs", () => {
|
||||
render(
|
||||
<LoggingCallbacksTable
|
||||
callbacks={[
|
||||
{
|
||||
name: "arize-eu",
|
||||
variables: baseVars,
|
||||
credentialName: "arize-eu",
|
||||
access: { teams: ["t1", "t2"], orgs: ["o1"] },
|
||||
resolvedScope: { global: false, teams: ["t1", "t2"], orgs: ["o1"] },
|
||||
},
|
||||
]}
|
||||
availableCallbacks={{}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("team: t1")).toBeInTheDocument();
|
||||
expect(screen.getByText("team: t2")).toBeInTheDocument();
|
||||
expect(screen.getByText("org: o1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a destination row edits access and deletes without exposing callback actions", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onEditAccess = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
const onTest = vi.fn();
|
||||
const callback = {
|
||||
name: "dest",
|
||||
variables: baseVars,
|
||||
credentialName: "dest",
|
||||
access: { global: true },
|
||||
resolvedScope: { global: true, teams: [], orgs: [] },
|
||||
};
|
||||
render(
|
||||
<LoggingCallbacksTable
|
||||
callbacks={[callback]}
|
||||
availableCallbacks={{}}
|
||||
onEditAccess={onEditAccess}
|
||||
onDelete={onDelete}
|
||||
onTest={onTest}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("callback-actions-dest-success"));
|
||||
expect(screen.queryByTestId("callback-action-test")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("callback-action-edit")).not.toBeInTheDocument();
|
||||
await user.click(await screen.findByTestId("destination-action-edit-access"));
|
||||
expect(onEditAccess).toHaveBeenCalledWith(callback);
|
||||
|
||||
await user.click(screen.getByTestId("callback-actions-dest-success"));
|
||||
await user.click(await screen.findByTestId("destination-action-delete"));
|
||||
expect(onDelete).toHaveBeenCalledWith(callback);
|
||||
expect(onTest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a config callback row renders an empty scope", () => {
|
||||
render(
|
||||
<LoggingCallbacksTable
|
||||
callbacks={[{ name: "datadog", type: "success", variables: baseVars }]}
|
||||
availableCallbacks={{}}
|
||||
/>,
|
||||
);
|
||||
const row = screen.getByText("datadog").closest("tr");
|
||||
expect(row).not.toBeNull();
|
||||
expect(within(row as HTMLElement).getByText("—")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives a destination and a config callback of the same name distinct row ids", () => {
|
||||
const errors: string[] = [];
|
||||
const spy = vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
||||
errors.push(args.map(String).join(" "));
|
||||
});
|
||||
|
||||
render(
|
||||
<LoggingCallbacksTable
|
||||
callbacks={[
|
||||
{ name: "arize", type: "success", variables: baseVars },
|
||||
{
|
||||
name: "arize",
|
||||
variables: baseVars,
|
||||
credentialName: "arize",
|
||||
access: { global: true },
|
||||
resolvedScope: { global: true, teams: [], orgs: [] },
|
||||
},
|
||||
]}
|
||||
availableCallbacks={{}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(errors.filter((e) => /same key/i.test(e))).toHaveLength(0);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("read-only admin actions", () => {
|
||||
// Regression: readOnly dropped the whole actions column, so an Admin Viewer lost Test
|
||||
// on pre-existing callback rows even though that role is backend-authorized for
|
||||
// /health/services. Only the mutating actions belong behind readOnly.
|
||||
const row = { name: "langfuse", variables: { ...baseVars }, type: "success" as const };
|
||||
|
||||
it("keeps Test and hides the mutating actions for a read-only admin", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoggingCallbacksTable callbacks={[row]} readOnly />);
|
||||
|
||||
await user.click(screen.getByTestId("callback-actions-langfuse-success"));
|
||||
|
||||
expect(await screen.findByTestId("callback-action-test")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("callback-action-edit")).toBeNull();
|
||||
expect(screen.queryByTestId("callback-action-delete")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps every action for a full admin", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoggingCallbacksTable callbacks={[row]} />);
|
||||
|
||||
await user.click(screen.getByTestId("callback-actions-langfuse-success"));
|
||||
|
||||
expect(await screen.findByTestId("callback-action-test")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("callback-action-edit")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("callback-action-delete")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("destination rows must not overstate what a destination does", () => {
|
||||
const destination = (over: Record<string, unknown> = {}) => ({
|
||||
name: "d1",
|
||||
variables: baseVars,
|
||||
credentialName: "d1",
|
||||
destinationLabel: "Generic OTLP Collector",
|
||||
resolvedScope: { global: true, teams: [], orgs: [] },
|
||||
...over,
|
||||
});
|
||||
|
||||
it("shows Not active, never a scope badge, when the backend cannot build the destination", () => {
|
||||
// Regression: the cell read credential_info.access alone, so a destination the
|
||||
// resolver excludes (no backend name, or values its adapter rejects) still rendered
|
||||
// "Global access" and read as live.
|
||||
render(
|
||||
<LoggingCallbacksTable
|
||||
callbacks={[destination({ resolvesToDestination: false }) as never]}
|
||||
availableCallbacks={{}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Not active")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Global access")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still shows the scope badge when the destination does build", () => {
|
||||
render(
|
||||
<LoggingCallbacksTable
|
||||
callbacks={[destination({ resolvesToDestination: true }) as never]}
|
||||
availableCallbacks={{}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Global access")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Not active")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the admin's own name for a destination named after a config callback", () => {
|
||||
// Regression: the name column applied the callback registry's display label, so a
|
||||
// destination the admin called "datadog" rendered as "Datadog", indistinguishable
|
||||
// from the real Datadog callback row.
|
||||
render(
|
||||
<LoggingCallbacksTable
|
||||
callbacks={[destination({ name: "datadog", credentialName: "datadog" }) as never]}
|
||||
availableCallbacks={{
|
||||
datadog: { litellm_callback_name: "datadog", litellm_callback_params: [], ui_callback_name: "Datadog" },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("datadog")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Datadog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no actions trigger for a read-only admin, rather than one that opens empty", () => {
|
||||
// Regression: readOnly suppressed both Edit scope and Delete, and destinations never
|
||||
// get Test, so the trigger opened a menu with zero items and looked broken.
|
||||
render(<LoggingCallbacksTable callbacks={[destination() as never]} availableCallbacks={{}} readOnly />);
|
||||
expect(screen.queryByTestId("callback-actions-d1-success")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still renders the actions trigger for a config callback a read-only admin can Test", async () => {
|
||||
render(
|
||||
<LoggingCallbacksTable
|
||||
callbacks={[{ name: "datadog", type: "success_and_failure", variables: baseVars }]}
|
||||
availableCallbacks={{}}
|
||||
readOnly
|
||||
/>,
|
||||
);
|
||||
const trigger = screen.getByTestId("callback-actions-datadog-success_and_failure");
|
||||
await userEvent.click(trigger);
|
||||
expect(await screen.findByTestId("callback-action-test")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
CallbackRow,
|
||||
callbackRowMode,
|
||||
getLoggingCallbacksTableColumns,
|
||||
isDestination,
|
||||
} from "./LoggingCallbacksTableColumns";
|
||||
import { AlertingObject } from "./types";
|
||||
|
||||
|
|
@ -21,7 +22,9 @@ type LoggingCallbacksProps = {
|
|||
onTest?: (callback: AlertingObject) => void | Promise<void>;
|
||||
onEdit?: (callback: AlertingObject) => void;
|
||||
onDelete?: (callback: AlertingObject) => void;
|
||||
onEditAccess?: (callback: AlertingObject) => void;
|
||||
onAdd?: () => void;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
function EmptyState() {
|
||||
|
|
@ -45,26 +48,33 @@ export const LoggingCallbacksTable: React.FC<LoggingCallbacksProps> = ({
|
|||
onTest = () => {},
|
||||
onEdit = () => {},
|
||||
onDelete = () => {},
|
||||
onEditAccess = () => {},
|
||||
onAdd = () => {},
|
||||
readOnly = false,
|
||||
}) => {
|
||||
const columns = useMemo(() => {
|
||||
const deps = { availableCallbacks, onTest, onEdit, onDelete };
|
||||
return getLoggingCallbacksTableColumns(deps);
|
||||
}, [availableCallbacks, onTest, onEdit, onDelete]);
|
||||
// A read-only admin keeps the actions column so Test stays reachable -- that role is
|
||||
// backend-authorized for /health/services. Dropping the column removed it entirely.
|
||||
return getLoggingCallbacksTableColumns({ availableCallbacks, onTest, onEdit, onDelete, onEditAccess, readOnly });
|
||||
}, [availableCallbacks, onTest, onEdit, onDelete, onEditAccess, readOnly]);
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex w-full flex-col gap-4">
|
||||
<h3 className="text-lg font-semibold tracking-tight text-foreground">Active Logging Callbacks</h3>
|
||||
<div>
|
||||
<Button onClick={onAdd}>
|
||||
<Plus />
|
||||
Add Callback
|
||||
</Button>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div>
|
||||
<Button onClick={onAdd}>
|
||||
<Plus />
|
||||
Add Callback
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<DataTable
|
||||
data={callbacks as CallbackRow[]}
|
||||
columns={columns}
|
||||
getRowId={(callback, index) => `${callback.name || index}-${callbackRowMode(callback)}`}
|
||||
getRowId={(callback, index) =>
|
||||
`${isDestination(callback) ? "destination" : "callback"}:${callback.name || index}-${callbackRowMode(callback)}`
|
||||
}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading callbacks…"
|
||||
noDataMessage={<EmptyState />}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { ColumnDef } from "@tanstack/react-table";
|
|||
import { MoreHorizontal, Pencil, Play, Trash2 } from "lucide-react";
|
||||
|
||||
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -30,6 +31,8 @@ export type AvailableCallbacks = Record<string, AvailableCallbackMeta>;
|
|||
|
||||
export const callbackRowMode = (record: CallbackRow): string => record.type || record.mode || "success";
|
||||
|
||||
export const isDestination = (record: AlertingObject): boolean => record.credentialName != null;
|
||||
|
||||
const CALLBACK_MODE_LABELS: Record<string, string> = {
|
||||
success: "Success",
|
||||
failure: "Failure",
|
||||
|
|
@ -42,14 +45,64 @@ function callbackModeTone(mode: string): StatusTone {
|
|||
return "info";
|
||||
}
|
||||
|
||||
function ScopeCell({ callback }: { callback: AlertingObject }) {
|
||||
const scope = callback.resolvedScope;
|
||||
const hasResolvedScope = scope?.global === true || [...(scope?.teams ?? []), ...(scope?.orgs ?? [])].length > 0;
|
||||
// A grant only routes traces if the credential also builds an exporter. Showing the
|
||||
// grant alone advertised destinations the resolver excludes, so a misconfigured one
|
||||
// read as live.
|
||||
if (callback.resolvesToDestination === false) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
title="This destination cannot be built from its stored values, so it receives no traces"
|
||||
>
|
||||
Not active
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (!scope || !hasResolvedScope) {
|
||||
return <span className="text-muted-foreground">—</span>;
|
||||
}
|
||||
if (scope.global) {
|
||||
return <Badge variant="secondary">Global access</Badge>;
|
||||
}
|
||||
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, 4);
|
||||
const remainder = items.length - shown.length;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{shown.map((item) => (
|
||||
<Badge key={`${item.kind}-${item.label}`} variant="outline">
|
||||
{item.kind}: {item.label}
|
||||
</Badge>
|
||||
))}
|
||||
{remainder > 0 && <Badge variant="secondary">+{remainder} more</Badge>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CallbackRowActionsProps {
|
||||
callback: CallbackRow;
|
||||
// A read-only admin keeps Test, which the backend authorizes for that role on
|
||||
// /health/services, and loses everything that mutates.
|
||||
readOnly?: boolean;
|
||||
onTest: (callback: AlertingObject) => void | Promise<void>;
|
||||
onEdit: (callback: AlertingObject) => void;
|
||||
onDelete: (callback: AlertingObject) => void;
|
||||
onEditAccess: (callback: AlertingObject) => void;
|
||||
}
|
||||
|
||||
function CallbackRowActions({ callback, onTest, onEdit, onDelete }: CallbackRowActionsProps) {
|
||||
function CallbackRowActions({ callback, onTest, onEdit, onDelete, onEditAccess, readOnly }: CallbackRowActionsProps) {
|
||||
const destination = isDestination(callback);
|
||||
// Destinations get no Test, so a read-only admin has no action left on one. Rendering
|
||||
// the trigger anyway opened an empty menu that looked broken rather than restricted.
|
||||
if (destination && readOnly) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
|
|
@ -60,19 +113,40 @@ function CallbackRowActions({ callback, onTest, onEdit, onDelete }: CallbackRowA
|
|||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem data-testid="callback-action-test" onClick={() => void onTest(callback)}>
|
||||
<Play />
|
||||
Test
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem data-testid="callback-action-edit" onClick={() => onEdit(callback)}>
|
||||
<Pencil />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" data-testid="callback-action-delete" onClick={() => onDelete(callback)}>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
{destination ? (
|
||||
!readOnly && (
|
||||
<DropdownMenuItem data-testid="destination-action-edit-access" onClick={() => onEditAccess(callback)}>
|
||||
<Pencil />
|
||||
Edit scope
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem data-testid="callback-action-test" onClick={() => void onTest(callback)}>
|
||||
<Play />
|
||||
Test
|
||||
</DropdownMenuItem>
|
||||
{!readOnly && (
|
||||
<DropdownMenuItem data-testid="callback-action-edit" onClick={() => onEdit(callback)}>
|
||||
<Pencil />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
data-testid={destination ? "destination-action-delete" : "callback-action-delete"}
|
||||
onClick={() => onDelete(callback)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
|
@ -83,6 +157,8 @@ interface LoggingCallbacksTableColumnsDeps {
|
|||
onTest: (callback: AlertingObject) => void | Promise<void>;
|
||||
onEdit: (callback: AlertingObject) => void;
|
||||
onDelete: (callback: AlertingObject) => void;
|
||||
onEditAccess: (callback: AlertingObject) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const getLoggingCallbacksTableColumns = ({
|
||||
|
|
@ -90,6 +166,8 @@ export const getLoggingCallbacksTableColumns = ({
|
|||
onTest,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onEditAccess,
|
||||
readOnly,
|
||||
}: LoggingCallbacksTableColumnsDeps): ColumnDef<CallbackRow>[] => [
|
||||
{
|
||||
id: "name",
|
||||
|
|
@ -99,11 +177,19 @@ export const getLoggingCallbacksTableColumns = ({
|
|||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const id = row.original.name;
|
||||
const displayName = availableCallbacks[id]?.ui_callback_name || id;
|
||||
// A destination keeps the name the admin gave it. Looking it up in the callback
|
||||
// registry renamed a destination called "datadog" to "Datadog", making it
|
||||
// indistinguishable in this column from the real Datadog callback row.
|
||||
const displayName = isDestination(row.original) ? id : availableCallbacks[id]?.ui_callback_name || id;
|
||||
return (
|
||||
<span className="block max-w-72 truncate text-sm font-medium" title={displayName}>
|
||||
{displayName}
|
||||
</span>
|
||||
<div>
|
||||
<span className="block max-w-72 truncate text-sm font-medium" title={displayName}>
|
||||
{displayName}
|
||||
</span>
|
||||
{row.original.destinationLabel && (
|
||||
<span className="block text-xs text-muted-foreground">{row.original.destinationLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
@ -114,10 +200,26 @@ export const getLoggingCallbacksTableColumns = ({
|
|||
size: 240,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
if (isDestination(row.original)) {
|
||||
return <span className="text-muted-foreground">—</span>;
|
||||
}
|
||||
const mode = callbackRowMode(row.original);
|
||||
return <StatusBadge tone={callbackModeTone(mode)} label={CALLBACK_MODE_LABELS[mode] || mode} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "access",
|
||||
meta: { title: "Scope", skeleton: "badge" },
|
||||
header: "Scope",
|
||||
size: 280,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) =>
|
||||
isDestination(row.original) ? (
|
||||
<ScopeCell callback={row.original} />
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
|
|
@ -127,7 +229,14 @@ export const getLoggingCallbacksTableColumns = ({
|
|||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<CallbackRowActions callback={row.original} onTest={onTest} onEdit={onEdit} onDelete={onDelete} />
|
||||
<CallbackRowActions
|
||||
callback={row.original}
|
||||
onTest={onTest}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onEditAccess={onEditAccess}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,6 +8,36 @@ export interface AlertingObject {
|
|||
// every row to render as "Success".
|
||||
type?: "success" | "failure" | "success_and_failure";
|
||||
variables: AlertingVariables;
|
||||
// Present only on rows backed by a logging credential (an OTEL trace
|
||||
// destination). Config-callback rows leave these unset, which is how the table
|
||||
// tells the two apart.
|
||||
credentialName?: string;
|
||||
destinationLabel?: string;
|
||||
access?: CredentialAccess;
|
||||
// The destination's whole stored credential_info. PATCH replaces
|
||||
// credential_info wholesale, so an access edit has to resend all of it.
|
||||
credentialInfo?: Record<string, unknown>;
|
||||
// The set of identities that route to this destination, resolved at render
|
||||
// time from credential_info.access. Display labels only -- ids are not
|
||||
// surfaced here. global=true bypasses the lists.
|
||||
resolvedScope?: ResolvedScope;
|
||||
// Whether the backend can actually build an exporter from this credential, decided
|
||||
// there by the same function the request-time resolver and the team/org disclosure
|
||||
// use. Read rather than recomputed: a second implementation of the adapter rules
|
||||
// here would drift, and the drift is what let a dead destination look active.
|
||||
resolvesToDestination?: boolean;
|
||||
}
|
||||
|
||||
export interface CredentialAccess {
|
||||
global?: boolean;
|
||||
teams?: string[];
|
||||
orgs?: string[];
|
||||
}
|
||||
|
||||
export interface ResolvedScope {
|
||||
global: boolean;
|
||||
teams: string[];
|
||||
orgs: string[];
|
||||
}
|
||||
|
||||
export interface AlertingVariables {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ interface CallbackConfig {
|
|||
displayName: string;
|
||||
logo?: string;
|
||||
supports_key_team_logging: boolean;
|
||||
dynamic_params: Record<string, "text" | "password" | "select" | "upload" | "number">;
|
||||
dynamic_params: Record<string, "text" | "password" | "select" | "upload" | "number" | "credential">;
|
||||
description: string;
|
||||
}
|
||||
|
||||
|
|
@ -112,6 +112,15 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
|
|||
},
|
||||
description: "Langfuse v3 OTEL Logging Integration",
|
||||
},
|
||||
{
|
||||
id: "weave_otel",
|
||||
displayName: "Weave OTEL",
|
||||
// OTEL v2 destination: admin-owned and routed by credential_info.access,
|
||||
// not configured as a per-team callback here.
|
||||
supports_key_team_logging: false,
|
||||
dynamic_params: {},
|
||||
description: "Weave (W&B) OTEL Logging Integration",
|
||||
},
|
||||
{
|
||||
id: "langsmith",
|
||||
displayName: "LangSmith",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import { Form, Select, Switch } from "antd";
|
||||
import React from "react";
|
||||
|
||||
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { CredentialAccess } from "../Settings/LoggingAndAlerts/LoggingCallbacks/types";
|
||||
|
||||
interface AccessControlFieldsProps {
|
||||
// value/onChange are optional so the component can be driven either directly
|
||||
// (the Add modal) or injected by an antd Form.Item (the Edit modal).
|
||||
value?: CredentialAccess;
|
||||
onChange?: (next: CredentialAccess) => void;
|
||||
}
|
||||
|
||||
// Admin-owned access for a logging destination: global (every request) or a set of
|
||||
// teams/orgs. Per-key targeting is intentionally absent here -- it lives on the key's
|
||||
// own page, since a key's token rotates on regenerate while team/org ids are stable.
|
||||
const AccessControlFields: React.FC<AccessControlFieldsProps> = ({ value = {}, onChange = () => {} }) => {
|
||||
const { data: teams } = useTeams();
|
||||
const { data: orgs } = useOrganizations();
|
||||
const isGlobal = value.global === true;
|
||||
|
||||
const teamOptions = (teams ?? []).map((t) => ({ value: t.team_id, label: t.team_alias || t.team_id }));
|
||||
const orgOptions = (orgs ?? []).map((o) => ({
|
||||
value: o.organization_id,
|
||||
label: o.organization_alias || o.organization_id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Global" tooltip="Routing scope: traces from every team and org export to this destination.">
|
||||
<Switch checked={isGlobal} onChange={(global) => onChange({ ...value, global })} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Teams" tooltip="Routing scope: only these teams' traffic exports to this destination.">
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
disabled={isGlobal}
|
||||
placeholder="Select teams"
|
||||
value={value.teams ?? []}
|
||||
onChange={(teamIds) => onChange({ ...value, teams: teamIds })}
|
||||
options={teamOptions}
|
||||
optionFilterProp="label"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Organizations" tooltip="Routing scope: only these orgs' traffic exports to this destination.">
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
disabled={isGlobal}
|
||||
placeholder="Select organizations"
|
||||
value={value.orgs ?? []}
|
||||
onChange={(orgIds) => onChange({ ...value, orgs: orgIds })}
|
||||
options={orgOptions}
|
||||
optionFilterProp="label"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccessControlFields;
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { credentialUpdateCall } from "../networking";
|
||||
import EditLoggingCredentialModal from "./EditLoggingCredentialModal";
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
credentialUpdateCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../molecules/notifications_manager", () => ({
|
||||
default: { success: vi.fn(), fromBackend: vi.fn() },
|
||||
}));
|
||||
|
||||
// The access picker fetches teams/orgs through react-query; this test is about the
|
||||
// PATCH body the modal builds, so the picker is stubbed out.
|
||||
vi.mock("./AccessControlFields", () => ({ default: () => null }));
|
||||
|
||||
describe("EditLoggingCredentialModal", () => {
|
||||
const mockUpdate = vi.mocked(credentialUpdateCall);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUpdate.mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
// Regression: PATCH /credentials replaces credential_info wholesale. Sending only
|
||||
// { access } dropped credential_type/description, which stops the row being a logging
|
||||
// destination at all -- it vanishes from resolved_logging_exporters and exports nothing.
|
||||
it("resends the destination's whole credential_info with only access swapped", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<EditLoggingCredentialModal
|
||||
accessToken="tok"
|
||||
credentialName="dest-1"
|
||||
access={{ global: false, teams: ["team-a"], orgs: [] }}
|
||||
credentialInfo={{
|
||||
credential_type: "logging",
|
||||
description: "langfuse_otel",
|
||||
host: "https://collector.internal",
|
||||
access: { global: false, teams: ["team-a"], orgs: [] },
|
||||
}}
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: /save/i }));
|
||||
|
||||
await waitFor(() => expect(mockUpdate).toHaveBeenCalled());
|
||||
const [, name, body] = mockUpdate.mock.calls[0];
|
||||
expect(name).toBe("dest-1");
|
||||
expect(body.credential_name).toBe("dest-1");
|
||||
// secrets are masked on read, so an access edit must not resend them
|
||||
expect(body.credential_values).toEqual({});
|
||||
// every sibling field survives the wholesale replace
|
||||
expect(body.credential_info).toMatchObject({
|
||||
credential_type: "logging",
|
||||
description: "langfuse_otel",
|
||||
host: "https://collector.internal",
|
||||
});
|
||||
expect(body.credential_info.access).toEqual({ global: false, teams: ["team-a"], orgs: [] });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { Form, Modal } from "antd";
|
||||
import React from "react";
|
||||
|
||||
import { CredentialAccess } from "../Settings/LoggingAndAlerts/LoggingCallbacks/types";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import { credentialUpdateCall } from "../networking";
|
||||
import AccessControlFields from "./AccessControlFields";
|
||||
|
||||
interface EditLoggingCredentialModalProps {
|
||||
accessToken: string;
|
||||
credentialName: string | null;
|
||||
access?: CredentialAccess;
|
||||
// The destination's stored credential_info. PATCH replaces credential_info
|
||||
// wholesale, so the whole object is resent with only access swapped; sending
|
||||
// access alone would drop credential_type/description and stop the row being
|
||||
// a logging destination at all.
|
||||
credentialInfo?: Record<string, unknown>;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
interface AccessForm {
|
||||
access?: CredentialAccess;
|
||||
}
|
||||
|
||||
const EditLoggingCredentialModal: React.FC<EditLoggingCredentialModalProps> = ({
|
||||
accessToken,
|
||||
credentialName,
|
||||
access,
|
||||
credentialInfo,
|
||||
open,
|
||||
onClose,
|
||||
onSaved,
|
||||
}) => {
|
||||
// destroyOnClose remounts the Form each open, so initialValues re-seeds from the
|
||||
// current destination -- no effect syncing prop into state.
|
||||
const [form] = Form.useForm<AccessForm>();
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!credentialName) return;
|
||||
const current = form.getFieldsValue().access ?? {};
|
||||
// Always send the full access object; a global grant supersedes team/org.
|
||||
const next: CredentialAccess = current.global
|
||||
? { global: true, teams: [], orgs: [] }
|
||||
: { global: false, teams: current.teams ?? [], orgs: current.orgs ?? [] };
|
||||
try {
|
||||
await credentialUpdateCall(accessToken, credentialName, {
|
||||
credential_name: credentialName,
|
||||
credential_values: {},
|
||||
credential_info: { ...(credentialInfo ?? {}), access: next },
|
||||
});
|
||||
NotificationsManager.success("Access updated");
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
NotificationsManager.fromBackend(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`Edit scope${credentialName ? ` — ${credentialName}` : ""}`}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onOk={handleSave}
|
||||
okText="Save"
|
||||
destroyOnClose
|
||||
>
|
||||
<Form<AccessForm> form={form} layout="vertical" preserve={false} initialValues={{ access: access ?? {} }}>
|
||||
<Form.Item name="access" noStyle>
|
||||
<AccessControlFields />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditLoggingCredentialModal;
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { CredentialAccess } from "../Settings/LoggingAndAlerts/LoggingCallbacks/types";
|
||||
import { credentialCreateCall } from "../networking";
|
||||
import { LOGGING_DESTINATION_BACKENDS } from "./loggingDestinationFields";
|
||||
|
||||
// The OTEL trace-destination backend ids, managed as admin-owned logging destinations
|
||||
// (credentials). The `langfuse` v2 SDK logger and the generic `otel` callback are
|
||||
// deliberately not here; they keep their existing global-callback behavior untouched.
|
||||
export const LOGGING_BACKEND_IDS: ReadonlySet<string> = new Set(LOGGING_DESTINATION_BACKENDS.map((b) => b.id));
|
||||
|
||||
// These backends are reachable two ways from the Add modal, so the dropdown carries a
|
||||
// prefixed option id for the destination branch. Without it a bare `arize` would be
|
||||
// ambiguous, and picking it would silently create an inert credential instead of the
|
||||
// proxy-wide callback the same label used to produce.
|
||||
export const DESTINATION_OPTION_PREFIX = "destination:";
|
||||
|
||||
export const backendLabel = (id?: string): string =>
|
||||
LOGGING_DESTINATION_BACKENDS.find((b) => b.id === id)?.label ?? id ?? "-";
|
||||
|
||||
export interface CreateLoggingCredentialInput {
|
||||
credentialName: string;
|
||||
backend: string;
|
||||
values: Record<string, string>;
|
||||
host?: string;
|
||||
access?: CredentialAccess;
|
||||
}
|
||||
|
||||
// One place that owns the logging-credential contract: the credential_type tag, the
|
||||
// backend in description, the non-secret host, and the admin-owned access grant.
|
||||
export const createLoggingCredential = async (accessToken: string, input: CreateLoggingCredentialInput) =>
|
||||
credentialCreateCall(accessToken, {
|
||||
credential_name: input.credentialName,
|
||||
credential_values: input.values,
|
||||
credential_info: {
|
||||
credential_type: "logging",
|
||||
description: input.backend,
|
||||
...(input.host ? { host: input.host } : {}),
|
||||
...(input.access ? { access: input.access } : {}),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { LOGGING_DESTINATION_BACKENDS } from "./loggingDestinationFields";
|
||||
|
||||
// Arize routes a trace to a project via the model_id / arize.project.name span
|
||||
// resource attribute, and its OTLP ingestion rejects any span that lacks it
|
||||
// ("model_id span resource attribute or arize.project.name span attribute is
|
||||
// required"). The backend reads that project from the credential's
|
||||
// arize_project_name value, so the create form must collect it as a required
|
||||
// field; without it every Arize destination created in the UI silently drops
|
||||
// 100% of its traces.
|
||||
describe("Arize logging destination fields", () => {
|
||||
const arize = LOGGING_DESTINATION_BACKENDS.find((b) => b.id === "arize");
|
||||
|
||||
it("exposes a required arize_project_name field", () => {
|
||||
expect(arize).toBeDefined();
|
||||
const projectField = arize!.fields.find((f) => f.name === "arize_project_name");
|
||||
expect(projectField).toBeDefined();
|
||||
expect(projectField!.optional).not.toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
// Create-time field shapes for an admin-owned logging destination, keyed by the
|
||||
// OTEL v2 backend it binds to. This is the inverse of the per-team picker: the
|
||||
// picker selects a destination by name; these fields are what an admin types when
|
||||
// CREATING the named destination in the registry. Keeping the raw keys here (the
|
||||
// admin registry) and out of the per-team form is the provider/logging separation.
|
||||
|
||||
export type LoggingFieldType = "text" | "password";
|
||||
|
||||
export interface LoggingField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: LoggingFieldType;
|
||||
optional?: boolean;
|
||||
// Example value shown as the input placeholder, so an admin knows the format.
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface LoggingDestinationBackend {
|
||||
id: string; // the callback_name the credential is bound under
|
||||
label: string;
|
||||
fields: LoggingField[];
|
||||
// The non-secret field that names the destination host/endpoint. Surfaced in the
|
||||
// list so an admin can tell e.g. an EU from a US destination apart.
|
||||
hostField: string;
|
||||
}
|
||||
|
||||
export const LOGGING_DESTINATION_BACKENDS: LoggingDestinationBackend[] = [
|
||||
{
|
||||
id: "langfuse_otel",
|
||||
label: "Langfuse OTEL",
|
||||
fields: [
|
||||
{
|
||||
name: "langfuse_host",
|
||||
label: "Langfuse Host",
|
||||
type: "text",
|
||||
placeholder: "https://cloud.langfuse.com",
|
||||
},
|
||||
{
|
||||
name: "langfuse_public_key",
|
||||
label: "Public Key",
|
||||
type: "password",
|
||||
placeholder: "pk-lf-00000000-0000-0000-0000-000000000000",
|
||||
},
|
||||
{
|
||||
name: "langfuse_secret_key",
|
||||
label: "Secret Key",
|
||||
type: "password",
|
||||
placeholder: "sk-lf-00000000-0000-0000-0000-000000000000",
|
||||
},
|
||||
],
|
||||
hostField: "langfuse_host",
|
||||
},
|
||||
{
|
||||
id: "arize",
|
||||
label: "Arize",
|
||||
fields: [
|
||||
{
|
||||
name: "arize_space_id",
|
||||
label: "Space ID",
|
||||
type: "password",
|
||||
placeholder: "U3BhY2U6MTIzNDU6YWJjZA==",
|
||||
},
|
||||
{
|
||||
name: "arize_api_key",
|
||||
label: "API Key",
|
||||
type: "password",
|
||||
placeholder: "ak-0000aaaa-1111-2222-3333-444455556666",
|
||||
},
|
||||
{
|
||||
name: "arize_project_name",
|
||||
label: "Project Name",
|
||||
type: "text",
|
||||
placeholder: "my-llm-app",
|
||||
},
|
||||
{
|
||||
name: "arize_endpoint",
|
||||
label: "Endpoint (gRPC)",
|
||||
type: "text",
|
||||
optional: true,
|
||||
placeholder: "https://otlp.arize.com/v1",
|
||||
},
|
||||
{
|
||||
name: "arize_http_endpoint",
|
||||
label: "Endpoint (HTTP)",
|
||||
type: "text",
|
||||
optional: true,
|
||||
placeholder: "http://collector.internal/v1/traces",
|
||||
},
|
||||
],
|
||||
hostField: "arize_endpoint",
|
||||
},
|
||||
{
|
||||
id: "weave_otel",
|
||||
label: "Weave",
|
||||
fields: [
|
||||
{
|
||||
name: "wandb_api_key",
|
||||
label: "W&B API Key",
|
||||
type: "password",
|
||||
placeholder: "0123456789abcdef0123456789abcdef01234567",
|
||||
},
|
||||
{
|
||||
name: "weave_project_id",
|
||||
label: "Project (entity/project)",
|
||||
type: "text",
|
||||
placeholder: "my-team/my-project",
|
||||
},
|
||||
{
|
||||
name: "weave_endpoint",
|
||||
label: "Endpoint",
|
||||
type: "text",
|
||||
optional: true,
|
||||
placeholder: "https://trace.wandb.ai",
|
||||
},
|
||||
],
|
||||
hostField: "weave_endpoint",
|
||||
},
|
||||
{
|
||||
id: "generic",
|
||||
label: "Generic OTLP Collector",
|
||||
fields: [
|
||||
{
|
||||
name: "otel_endpoint",
|
||||
label: "OTLP Endpoint",
|
||||
type: "text",
|
||||
placeholder: "https://collector.example.com:4318/v1/traces",
|
||||
},
|
||||
{
|
||||
name: "otel_headers",
|
||||
label: "Headers (k=v,k2=v2)",
|
||||
type: "text",
|
||||
optional: true,
|
||||
placeholder: "x-api-key=abc123,x-team=42",
|
||||
},
|
||||
],
|
||||
hostField: "otel_endpoint",
|
||||
},
|
||||
];
|
||||
|
|
@ -38,3 +38,25 @@ describe("LoggingSettingsView logos", () => {
|
|||
expect(screen.getByText("C")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LoggingSettingsView scoped exporters", () => {
|
||||
it("says nothing about exporters when the caller has not resolved them", () => {
|
||||
render(<LoggingSettingsView loggingConfigs={[]} disabledCallbacks={[]} />);
|
||||
|
||||
expect(screen.queryByText("No logging exporters assigned")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Logging Exporters")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports none only when the caller resolved an empty list", () => {
|
||||
render(<LoggingSettingsView loggingConfigs={[]} disabledCallbacks={[]} scopedExporters={[]} />);
|
||||
|
||||
expect(screen.getByText("No logging exporters assigned")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists the exporters the caller resolved", () => {
|
||||
render(<LoggingSettingsView loggingConfigs={[]} disabledCallbacks={[]} scopedExporters={["langfuse-eu"]} />);
|
||||
|
||||
expect(screen.getByText("langfuse-eu")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No logging exporters assigned")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ interface LoggingConfig {
|
|||
interface LoggingSettingsViewProps {
|
||||
loggingConfigs?: LoggingConfig[];
|
||||
disabledCallbacks?: string[];
|
||||
// Destinations that route to this identity via credential_info.access
|
||||
// (teams/orgs/global), resolved server-side. Display only. Left undefined by a
|
||||
// caller that has not resolved them, which hides the section rather than
|
||||
// asserting there are none.
|
||||
scopedExporters?: string[];
|
||||
variant?: "card" | "inline";
|
||||
className?: string;
|
||||
}
|
||||
|
|
@ -20,6 +25,7 @@ interface LoggingSettingsViewProps {
|
|||
export function LoggingSettingsView({
|
||||
loggingConfigs = [],
|
||||
disabledCallbacks = [],
|
||||
scopedExporters,
|
||||
variant = "card",
|
||||
className = "",
|
||||
}: LoggingSettingsViewProps) {
|
||||
|
|
@ -57,6 +63,30 @@ export function LoggingSettingsView({
|
|||
|
||||
const content = (
|
||||
<div className="space-y-6">
|
||||
{scopedExporters !== undefined && (
|
||||
<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">{scopedExporters.length}</Tag>
|
||||
</div>
|
||||
{scopedExporters.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{scopedExporters.map((name, index) => (
|
||||
<Tag key={index} color="geekblue">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logging Integrations Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -636,3 +636,51 @@ describe("getAutoRouterClassifierDefaultPromptCall", () => {
|
|||
expect(String(mockFetch.mock.calls[1][0])).not.toContain("tier_labels");
|
||||
});
|
||||
});
|
||||
|
||||
describe("credential path encoding", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const mockOk = () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: vi.fn().mockResolvedValue("{}"),
|
||||
} as any);
|
||||
global.fetch = mockFetch as any;
|
||||
return mockFetch;
|
||||
};
|
||||
|
||||
const requestedUrl = (mockFetch: ReturnType<typeof mockOk>): URL => {
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
const urlStr = typeof url === "string" ? url : (url as Request).url;
|
||||
return new URL(urlStr, "http://example.com");
|
||||
};
|
||||
|
||||
it("encodes the credential name on delete so the request cannot address a different credential", async () => {
|
||||
const mockFetch = mockOk();
|
||||
|
||||
await Networking.credentialDeleteCall("token", "dest/other?force=1");
|
||||
|
||||
const parsed = requestedUrl(mockFetch);
|
||||
expect(parsed.pathname.endsWith("/credentials/dest%2Fother%3Fforce%3D1")).toBe(true);
|
||||
expect(parsed.search).toBe("");
|
||||
});
|
||||
|
||||
it("encodes the credential name on update so the patch reaches the named credential", async () => {
|
||||
const mockFetch = mockOk();
|
||||
|
||||
await Networking.credentialUpdateCall("token", "dest/other?force=1", { credential_info: {} });
|
||||
|
||||
const parsed = requestedUrl(mockFetch);
|
||||
expect(parsed.pathname.endsWith("/credentials/dest%2Fother%3Fforce%3D1")).toBe(true);
|
||||
expect(parsed.search).toBe("");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -245,6 +245,7 @@ export interface Organization {
|
|||
users: any[] | null;
|
||||
members: any[] | null;
|
||||
object_permission?: ObjectPermission | null;
|
||||
resolved_logging_exporters?: string[] | null;
|
||||
}
|
||||
|
||||
export interface CredentialItem {
|
||||
|
|
@ -254,6 +255,19 @@ export interface CredentialItem {
|
|||
custom_llm_provider?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
// "logging" tags an admin-owned trace destination (Option A: lives in the
|
||||
// free-form credential_info, no schema migration). Absent = a provider credential.
|
||||
credential_type?: string;
|
||||
// Non-secret destination host/endpoint, surfaced in the logging credentials list.
|
||||
host?: string;
|
||||
// Admin-owned access grant for a logging destination: who may see/assign it.
|
||||
// global reaches everyone; teams/orgs list ids. Visibility only -- on its own it
|
||||
// never enables tracing for a request.
|
||||
access?: {
|
||||
global?: boolean;
|
||||
teams?: string[];
|
||||
orgs?: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -2627,7 +2641,7 @@ export const credentialGetCall = async (accessToken: string, credentialName: str
|
|||
|
||||
export const credentialDeleteCall = async (accessToken: string, credentialName: string) => {
|
||||
try {
|
||||
const data = await apiClient.delete(`/credentials/${credentialName}`, { accessToken });
|
||||
const data = await apiClient.delete(`/credentials/${encodeURIComponent(credentialName)}`, { accessToken });
|
||||
return data;
|
||||
// Handle success - you might want to update some state or UI based on the created key
|
||||
} catch (error) {
|
||||
|
|
@ -2651,7 +2665,7 @@ export const credentialUpdateCall = async (
|
|||
}
|
||||
}
|
||||
|
||||
const data = await apiClient.patch(`/credentials/${credentialName}`, {
|
||||
const data = await apiClient.patch(`/credentials/${encodeURIComponent(credentialName)}`, {
|
||||
accessToken,
|
||||
body: {
|
||||
...formValues, // Include formValues in the request body
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||
import { useVisitedTabs } from "@/hooks/useVisitedTabs";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
|
@ -58,6 +59,11 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
|
||||
const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]);
|
||||
|
||||
const loggingExporterBadges = useMemo(
|
||||
() => (orgData?.resolved_logging_exporters ?? []).map((name) => ({ name })),
|
||||
[orgData],
|
||||
);
|
||||
|
||||
const handleMemberAdd = async (values: any) => {
|
||||
try {
|
||||
if (accessToken == null) {
|
||||
|
|
@ -242,6 +248,23 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Logging Exporters</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{loggingExporterBadges.length > 0 ? (
|
||||
loggingExporterBadges.map((exporter, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{exporter.name}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground">None</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ObjectPermissionsView
|
||||
objectPermission={orgData.object_permission}
|
||||
variant="card"
|
||||
|
|
@ -325,6 +348,20 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
|
|||
</div>
|
||||
<div>Reset: {orgData.litellm_budget_table.budget_duration || "Never"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Logging Exporters</p>
|
||||
{loggingExporterBadges.length > 0 ? (
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{loggingExporterBadges.map((exporter, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{exporter.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">None</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ObjectPermissionsView
|
||||
objectPermission={orgData.object_permission}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,27 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { Form } from "antd";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { alertingSettingsCall, getCallbackConfigsCall, getCallbacksCall } from "./networking";
|
||||
import Settings, { backendCallbackLogoSrc, CallbackSelector } from "./settings";
|
||||
|
||||
type SettingsTestProps = {
|
||||
accessToken: string | null;
|
||||
userRole: string | null;
|
||||
userID: string | null;
|
||||
premiumUser: boolean;
|
||||
};
|
||||
|
||||
// Settings (and its CloudZero cost-tracking child) renders react-query hooks, so
|
||||
// every render must sit under a QueryClientProvider. Retries off so a failed
|
||||
// query surfaces immediately instead of hanging the test.
|
||||
const renderSettings = (props: SettingsTestProps) =>
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<Settings {...props} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
vi.mock("./networking", () => ({
|
||||
getCallbacksCall: vi.fn(),
|
||||
getCallbackConfigsCall: vi.fn(),
|
||||
|
|
@ -40,6 +58,12 @@ vi.mock("./CloudZeroCostTracking/CloudZeroCostTracking", () => ({
|
|||
default: () => <div>Mock CloudZero Cost Tracking</div>,
|
||||
}));
|
||||
|
||||
let credentialsFixture: { credentials: unknown[] } = { credentials: [] };
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/credentials/useCredentials", () => ({
|
||||
useCredentials: () => ({ data: credentialsFixture, refetch: vi.fn() }),
|
||||
}));
|
||||
|
||||
// Polyfill ResizeObserver for components relying on it in tests
|
||||
if (typeof window !== "undefined" && !window.ResizeObserver) {
|
||||
window.ResizeObserver = class ResizeObserver {
|
||||
|
|
@ -68,7 +92,7 @@ beforeAll(() => {
|
|||
describe("Settings", () => {
|
||||
const defaultProps = {
|
||||
accessToken: "token",
|
||||
userRole: "admin",
|
||||
userRole: "Admin",
|
||||
userID: "user-123",
|
||||
premiumUser: false,
|
||||
};
|
||||
|
|
@ -78,6 +102,7 @@ describe("Settings", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
credentialsFixture = { credentials: [] };
|
||||
mockGetCallbacksCall.mockResolvedValue({
|
||||
callbacks: [],
|
||||
available_callbacks: [],
|
||||
|
|
@ -88,7 +113,7 @@ describe("Settings", () => {
|
|||
});
|
||||
|
||||
it("should render the logging callbacks tab when access token is provided", async () => {
|
||||
const { getByText } = render(<Settings {...defaultProps} />);
|
||||
const { getByText } = renderSettings(defaultProps);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText("Active Logging Callbacks")).toBeInTheDocument();
|
||||
|
|
@ -96,7 +121,7 @@ describe("Settings", () => {
|
|||
});
|
||||
|
||||
it("should display additional settings tabs", async () => {
|
||||
const { getByText } = render(<Settings {...defaultProps} />);
|
||||
const { getByText } = renderSettings(defaultProps);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText("CloudZero Cost Tracking")).toBeInTheDocument();
|
||||
|
|
@ -107,7 +132,7 @@ describe("Settings", () => {
|
|||
});
|
||||
|
||||
it("should load callback configs from the backend when access token is provided", async () => {
|
||||
render(<Settings {...defaultProps} />);
|
||||
renderSettings(defaultProps);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetCallbackConfigsCall).toHaveBeenCalledWith(defaultProps.accessToken);
|
||||
|
|
@ -115,35 +140,30 @@ describe("Settings", () => {
|
|||
});
|
||||
|
||||
it("should display edit modal with fields when edit is clicked", async () => {
|
||||
// Datadog is a plain config callback that renders a row with the legacy
|
||||
// Test/Edit/Delete actions. Config-owned OTEL callbacks (arize, langfuse_otel,
|
||||
// etc.) also render as their own rows now; see the regression test below.
|
||||
const mockCallback = {
|
||||
name: "langfuse",
|
||||
name: "datadog",
|
||||
variables: {
|
||||
LANGFUSE_PUBLIC_KEY: "test-public-key",
|
||||
LANGFUSE_SECRET_KEY: "test-secret-key",
|
||||
LANGFUSE_HOST: "https://test.langfuse.com",
|
||||
SLACK_WEBHOOK_URL: null,
|
||||
OPENMETER_API_KEY: null,
|
||||
DD_API_KEY: "test-api-key",
|
||||
DD_SITE: "us5.datadoghq.com",
|
||||
},
|
||||
};
|
||||
|
||||
const mockCallbackConfig = {
|
||||
id: "langfuse",
|
||||
displayName: "Langfuse",
|
||||
id: "datadog",
|
||||
displayName: "Datadog",
|
||||
dynamic_params: {
|
||||
LANGFUSE_PUBLIC_KEY: {
|
||||
type: "text",
|
||||
ui_name: "Public Key",
|
||||
required: true,
|
||||
},
|
||||
LANGFUSE_SECRET_KEY: {
|
||||
DD_API_KEY: {
|
||||
type: "password",
|
||||
ui_name: "Secret Key",
|
||||
ui_name: "API Key",
|
||||
required: true,
|
||||
},
|
||||
LANGFUSE_HOST: {
|
||||
DD_SITE: {
|
||||
type: "text",
|
||||
ui_name: "Host",
|
||||
required: false,
|
||||
ui_name: "Site",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -151,10 +171,10 @@ describe("Settings", () => {
|
|||
mockGetCallbacksCall.mockResolvedValue({
|
||||
callbacks: [mockCallback],
|
||||
available_callbacks: {
|
||||
langfuse: {
|
||||
litellm_callback_name: "langfuse",
|
||||
litellm_callback_params: ["LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "LANGFUSE_HOST"],
|
||||
ui_callback_name: "Langfuse",
|
||||
datadog: {
|
||||
litellm_callback_name: "datadog",
|
||||
litellm_callback_params: ["DD_API_KEY", "DD_SITE"],
|
||||
ui_callback_name: "Datadog",
|
||||
},
|
||||
},
|
||||
alerts: [],
|
||||
|
|
@ -163,17 +183,17 @@ describe("Settings", () => {
|
|||
mockGetCallbackConfigsCall.mockResolvedValue([mockCallbackConfig]);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const { getByText } = render(<Settings {...defaultProps} />);
|
||||
const { getByText } = renderSettings(defaultProps);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText("Active Logging Callbacks")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText("Langfuse")).toBeInTheDocument();
|
||||
expect(getByText("Datadog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByTestId("callback-actions-langfuse-success"));
|
||||
await user.click(screen.getByTestId("callback-actions-datadog-success"));
|
||||
await user.click(await screen.findByTestId("callback-action-edit"));
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -181,12 +201,63 @@ describe("Settings", () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText("Public Key")).toBeInTheDocument();
|
||||
expect(getByText("Secret Key")).toBeInTheDocument();
|
||||
expect(getByText("Host")).toBeInTheDocument();
|
||||
expect(getByText("API Key")).toBeInTheDocument();
|
||||
expect(getByText("Site")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render a config-owned OTEL callback (langfuse_otel) as its own row", async () => {
|
||||
// Regression: a proxy-wide langfuse_otel/arize/weave/generic callback configured
|
||||
// via /config/update must stay visible and manageable in the table. It was being
|
||||
// filtered out by backend id, removing config-owned rows (not just duplicates).
|
||||
mockGetCallbacksCall.mockResolvedValue({
|
||||
callbacks: [{ name: "langfuse_otel", variables: { LANGFUSE_PUBLIC_KEY: "pk", LANGFUSE_SECRET_KEY: "sk" } }],
|
||||
available_callbacks: {
|
||||
langfuse_otel: {
|
||||
litellm_callback_name: "langfuse_otel",
|
||||
litellm_callback_params: ["LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY"],
|
||||
ui_callback_name: "Langfuse OTEL",
|
||||
},
|
||||
},
|
||||
alerts: [],
|
||||
});
|
||||
mockGetCallbackConfigsCall.mockResolvedValue([
|
||||
{ id: "langfuse_otel", displayName: "Langfuse OTEL", dynamic_params: {} },
|
||||
]);
|
||||
|
||||
const { getByText } = renderSettings(defaultProps);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText("Active Logging Callbacks")).toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(getByText("Langfuse OTEL")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should keep rendering every destination when one stored access scope is not a list", async () => {
|
||||
credentialsFixture = {
|
||||
credentials: [
|
||||
{
|
||||
credential_name: "legacy-shape",
|
||||
credential_info: { credential_type: "logging", description: "langfuse_otel", access: { teams: "team-1" } },
|
||||
},
|
||||
{
|
||||
credential_name: "well-formed",
|
||||
credential_info: { credential_type: "logging", description: "langfuse_otel", access: { teams: ["team-2"] } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { getByText } = renderSettings(defaultProps);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText("Active Logging Callbacks")).toBeInTheDocument();
|
||||
});
|
||||
expect(getByText("legacy-shape")).toBeInTheDocument();
|
||||
expect(getByText("well-formed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should hold the callbacks table in loading state until the fetch settles", async () => {
|
||||
let resolveCallbacks: (value: {
|
||||
callbacks: never[];
|
||||
|
|
@ -199,7 +270,7 @@ describe("Settings", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
render(<Settings {...defaultProps} />);
|
||||
renderSettings(defaultProps);
|
||||
|
||||
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
|
||||
|
||||
|
|
@ -214,7 +285,7 @@ describe("Settings", () => {
|
|||
});
|
||||
|
||||
it("should resolve loading without fetching when the user id is missing", async () => {
|
||||
render(<Settings {...defaultProps} userID={null as unknown as string} />);
|
||||
renderSettings({ ...defaultProps, userID: null });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("skeleton-row")).not.toBeInTheDocument();
|
||||
|
|
@ -224,7 +295,7 @@ describe("Settings", () => {
|
|||
});
|
||||
|
||||
it("should display CloudZero Cost Tracking tab", async () => {
|
||||
const { getByText } = render(<Settings {...defaultProps} />);
|
||||
const { getByText } = renderSettings(defaultProps);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText("Active Logging Callbacks")).toBeInTheDocument();
|
||||
|
|
@ -274,3 +345,45 @@ describe("CallbackSelector logos", () => {
|
|||
expect(screen.getByText("N")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Add Callback dropdown", () => {
|
||||
// Regression: the four OTEL backend ids were filtered out of the config-owned
|
||||
// callback list and re-added as destinations under the SAME ids, so picking "Arize"
|
||||
// silently switched from creating a proxy-wide callback (/config/update) to creating
|
||||
// a by-default-inert logging credential (/credentials). Both paths must be offered,
|
||||
// and distinguishable, so the pre-existing flow still exists.
|
||||
const defaultProps = {
|
||||
accessToken: "token",
|
||||
userRole: "Admin",
|
||||
userID: "user-123",
|
||||
premiumUser: false,
|
||||
};
|
||||
|
||||
it("offers the config-owned OTEL callback and the scoped destination as separate options", async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(alertingSettingsCall).mockResolvedValue([]);
|
||||
vi.mocked(getCallbacksCall).mockResolvedValue({
|
||||
callbacks: [],
|
||||
available_callbacks: {
|
||||
arize: {
|
||||
litellm_callback_name: "arize",
|
||||
litellm_callback_params: ["ARIZE_SPACE_ID", "ARIZE_API_KEY"],
|
||||
ui_callback_name: "Arize",
|
||||
},
|
||||
},
|
||||
alerts: [],
|
||||
});
|
||||
vi.mocked(getCallbackConfigsCall).mockResolvedValue([{ id: "arize", displayName: "Arize", dynamic_params: {} }]);
|
||||
|
||||
const { getByText } = renderSettings(defaultProps);
|
||||
await waitFor(() => {
|
||||
expect(getByText("Active Logging Callbacks")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(getByText("Add Callback"));
|
||||
fireEvent.mouseDown(await screen.findByRole("combobox"));
|
||||
|
||||
expect(await screen.findByText("Arize")).toBeInTheDocument();
|
||||
expect(screen.getByText("Arize (scoped destination)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import AlertingSettings from "./alerting/alerting_settings";
|
|||
import CloudZeroCostTracking from "./CloudZeroCostTracking/CloudZeroCostTracking";
|
||||
import DeleteResourceModal from "./common_components/DeleteResourceModal";
|
||||
import {
|
||||
credentialDeleteCall,
|
||||
deleteCallback,
|
||||
getCallbackConfigsCall,
|
||||
getCallbacksCall,
|
||||
|
|
@ -37,7 +38,19 @@ import {
|
|||
setCallbacksCall,
|
||||
} from "./networking";
|
||||
import { LoggingCallbacksTable } from "./Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable";
|
||||
import { AlertingObject } from "./Settings/LoggingAndAlerts/LoggingCallbacks/types";
|
||||
import { AlertingObject, CredentialAccess, ResolvedScope } from "./Settings/LoggingAndAlerts/LoggingCallbacks/types";
|
||||
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
|
||||
import { canReadCredentialsRole, isProxyAdminRole } from "@/utils/roles";
|
||||
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 {
|
||||
backendLabel,
|
||||
createLoggingCredential,
|
||||
DESTINATION_OPTION_PREFIX,
|
||||
} from "./logging_credentials/loggingCredentialApi";
|
||||
import { LOGGING_DESTINATION_BACKENDS } from "./logging_credentials/loggingDestinationFields";
|
||||
import { parseErrorMessage } from "./shared/errorUtils";
|
||||
interface SettingsPageProps {
|
||||
accessToken: string | null;
|
||||
|
|
@ -236,6 +249,61 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
const [isAddingCallback, setIsAddingCallback] = useState(false);
|
||||
const [isDeletingCallback, setIsDeletingCallback] = useState(false);
|
||||
|
||||
// OTEL trace destinations are proxy-admin-managed credentials tagged
|
||||
// credential_type=logging; they share the one Active Logging Callbacks table as
|
||||
// rows alongside config callbacks. Only a proxy admin (or admin-viewer, read-only)
|
||||
// may read them, so non-admins skip the fetch entirely.
|
||||
const isProxyAdmin = userRole != null && isProxyAdminRole(userRole);
|
||||
const { data: credentialData, refetch: refetchCredentials } = useCredentials(canReadCredentialsRole(userRole));
|
||||
const { data: teamsData } = useTeams();
|
||||
const { data: orgsData } = useOrganizations();
|
||||
const [editAccessFor, setEditAccessFor] = useState<{
|
||||
name: string;
|
||||
access?: CredentialAccess;
|
||||
credentialInfo?: Record<string, unknown>;
|
||||
} | null>(null);
|
||||
// access for the destination branch of the unified Add modal
|
||||
const [addAccess, setAddAccess] = useState<CredentialAccess>({});
|
||||
const addingDestination = selectedCallback != null && selectedCallback.startsWith(DESTINATION_OPTION_PREFIX);
|
||||
const selectedDestinationBackend = addingDestination
|
||||
? selectedCallback.slice(DESTINATION_OPTION_PREFIX.length)
|
||||
: null;
|
||||
const addingDestinationFields =
|
||||
LOGGING_DESTINATION_BACKENDS.find((b) => b.id === selectedDestinationBackend)?.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;
|
||||
};
|
||||
|
||||
const asIdList = (value: unknown): string[] =>
|
||||
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
||||
|
||||
const resolveScope = (access?: CredentialAccess): ResolvedScope => ({
|
||||
global: access?.global === true,
|
||||
teams: asIdList(access?.teams).map(teamAlias),
|
||||
orgs: asIdList(access?.orgs).map(orgAlias),
|
||||
});
|
||||
|
||||
const destinationRows: AlertingObject[] = (credentialData?.credentials ?? [])
|
||||
.filter((c) => c.credential_info?.credential_type === "logging")
|
||||
.map((c) => ({
|
||||
name: c.credential_name,
|
||||
variables: {} as AlertingObject["variables"],
|
||||
credentialName: c.credential_name,
|
||||
destinationLabel: c.credential_info?.host
|
||||
? `${backendLabel(c.credential_info?.description)} · ${c.credential_info.host}`
|
||||
: backendLabel(c.credential_info?.description),
|
||||
access: c.credential_info?.access,
|
||||
credentialInfo: c.credential_info as Record<string, unknown> | undefined,
|
||||
resolvedScope: resolveScope(c.credential_info?.access),
|
||||
resolvesToDestination: (c as { resolves_to_destination?: boolean }).resolves_to_destination,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
|
|
@ -374,6 +442,34 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
if (!new_callback) {
|
||||
return;
|
||||
}
|
||||
if (new_callback.startsWith(DESTINATION_OPTION_PREFIX) && accessToken) {
|
||||
const backendId = new_callback.slice(DESTINATION_OPTION_PREFIX.length);
|
||||
const backendDef = LOGGING_DESTINATION_BACKENDS.find((b) => b.id === backendId);
|
||||
const fields = backendDef?.fields ?? [];
|
||||
const values = Object.fromEntries(
|
||||
fields.filter((f) => formValues[f.name]).map((f) => [f.name, formValues[f.name]]),
|
||||
);
|
||||
const host = backendDef ? formValues[backendDef.hostField] : undefined;
|
||||
const hasAccess = addAccess.global || addAccess.teams?.length || addAccess.orgs?.length;
|
||||
try {
|
||||
await createLoggingCredential(accessToken, {
|
||||
credentialName: formValues.credential_name,
|
||||
backend: backendId,
|
||||
values,
|
||||
host,
|
||||
access: hasAccess ? addAccess : undefined,
|
||||
});
|
||||
NotificationsManager.success("Logging destination created");
|
||||
refetchCredentials();
|
||||
setShowAddCallbacksModal(false);
|
||||
setSelectedCallback(null);
|
||||
setAddAccess({});
|
||||
addForm.resetFields();
|
||||
} catch (error) {
|
||||
NotificationsManager.fromBackend(parseErrorMessage(error));
|
||||
}
|
||||
return;
|
||||
}
|
||||
await handleCallbackSubmit(formValues, new_callback, false);
|
||||
};
|
||||
|
||||
|
|
@ -422,13 +518,22 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
|
||||
try {
|
||||
setIsDeletingCallback(true);
|
||||
await deleteCallback(accessToken, callbackToDelete.name);
|
||||
NotificationsManager.success(`Callback ${callbackToDelete.name} deleted successfully`);
|
||||
|
||||
// Refresh the callbacks list
|
||||
if (userID && userRole) {
|
||||
const data = await getCallbacksCall(accessToken, userID, userRole);
|
||||
setCallbacks(data.callbacks);
|
||||
// A destination row carries a credentialName; it is a logging credential and is
|
||||
// deleted (with its stored collector secrets) through the credential endpoint. A
|
||||
// plain config callback is deleted through the callback endpoint. Both run only
|
||||
// after the same delete confirmation, so a mis-click can't drop either instantly.
|
||||
if (callbackToDelete.credentialName) {
|
||||
await credentialDeleteCall(accessToken, callbackToDelete.credentialName);
|
||||
NotificationsManager.success("Logging destination deleted");
|
||||
refetchCredentials();
|
||||
} else {
|
||||
await deleteCallback(accessToken, callbackToDelete.name);
|
||||
NotificationsManager.success(`Callback ${callbackToDelete.name} deleted successfully`);
|
||||
// Refresh the callbacks list
|
||||
if (userID && userRole) {
|
||||
const data = await getCallbacksCall(accessToken, userID, userRole);
|
||||
setCallbacks(data.callbacks);
|
||||
}
|
||||
}
|
||||
|
||||
setShowDeleteConfirmModal(false);
|
||||
|
|
@ -459,14 +564,19 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
<TabPanels>
|
||||
<TabPanel>
|
||||
<LoggingCallbacksTable
|
||||
callbacks={callbacks}
|
||||
callbacks={[...callbacks, ...destinationRows]}
|
||||
availableCallbacks={allCallbacks}
|
||||
isLoading={isLoadingCallbacks}
|
||||
readOnly={!isProxyAdmin}
|
||||
onAdd={() => setShowAddCallbacksModal(true)}
|
||||
onEdit={(cb) => {
|
||||
setSelectedEditCallback(cb);
|
||||
setShowEditCallback(true);
|
||||
}}
|
||||
onEditAccess={(cb) =>
|
||||
cb.credentialName &&
|
||||
setEditAccessFor({ name: cb.credentialName, access: cb.access, credentialInfo: cb.credentialInfo })
|
||||
}
|
||||
onDelete={(cb) => handleDeleteCallback(cb)}
|
||||
onTest={async (cb) => {
|
||||
try {
|
||||
|
|
@ -477,6 +587,17 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
}
|
||||
}}
|
||||
/>
|
||||
{accessToken && (
|
||||
<EditLoggingCredentialModal
|
||||
accessToken={accessToken}
|
||||
credentialName={editAccessFor?.name ?? null}
|
||||
access={editAccessFor?.access}
|
||||
credentialInfo={editAccessFor?.credentialInfo}
|
||||
open={editAccessFor != null}
|
||||
onClose={() => setEditAccessFor(null)}
|
||||
onSaved={() => refetchCredentials()}
|
||||
/>
|
||||
)}
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<div className="p-8">
|
||||
|
|
@ -585,6 +706,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
setShowAddCallbacksModal(false);
|
||||
setSelectedCallback(null);
|
||||
setSelectedCallbackParams([]);
|
||||
setAddAccess({});
|
||||
}}
|
||||
footer={null}
|
||||
>
|
||||
|
|
@ -606,16 +728,52 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
labelAlign="left"
|
||||
>
|
||||
<CallbackSelector
|
||||
callbackConfigs={callbackConfigs}
|
||||
callbackConfigs={[
|
||||
...callbackConfigs,
|
||||
...LOGGING_DESTINATION_BACKENDS.map((b) => ({
|
||||
id: `${DESTINATION_OPTION_PREFIX}${b.id}`,
|
||||
displayName: `${b.label} (scoped destination)`,
|
||||
logo: "",
|
||||
})),
|
||||
]}
|
||||
selectedCallback={selectedCallback}
|
||||
onCallbackChange={handleSelectedCallbackChange}
|
||||
/>
|
||||
|
||||
<DynamicParamsFields
|
||||
params={selectedCallbackParams}
|
||||
callbackConfigs={callbackConfigs}
|
||||
selectedCallback={selectedCallback}
|
||||
/>
|
||||
{addingDestination ? (
|
||||
<div className="space-y-4 mt-6 p-4 bg-gray-50 rounded-lg border">
|
||||
<FormItem
|
||||
label={<span className="text-sm font-medium text-gray-700">Name</span>}
|
||||
name="credential_name"
|
||||
rules={[{ required: true, message: "Please enter a name" }]}
|
||||
>
|
||||
<Input size="large" placeholder="e.g. langfuse-eu" />
|
||||
</FormItem>
|
||||
{addingDestinationFields.map((f) => (
|
||||
<FormItem
|
||||
key={f.name}
|
||||
label={<span className="text-sm font-medium text-gray-700">{f.label}</span>}
|
||||
name={f.name}
|
||||
rules={
|
||||
f.optional ? undefined : [{ required: true, message: `Please enter the ${f.label.toLowerCase()}` }]
|
||||
}
|
||||
>
|
||||
{f.type === "password" ? (
|
||||
<Input.Password size="large" placeholder={f.placeholder} />
|
||||
) : (
|
||||
<Input size="large" placeholder={f.placeholder} />
|
||||
)}
|
||||
</FormItem>
|
||||
))}
|
||||
<AccessControlFields value={addAccess} onChange={setAddAccess} />
|
||||
</div>
|
||||
) : (
|
||||
<DynamicParamsFields
|
||||
params={selectedCallbackParams}
|
||||
callbackConfigs={callbackConfigs}
|
||||
selectedCallback={selectedCallback}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-6 mt-6 border-t border-gray-200">
|
||||
<Button2
|
||||
|
|
@ -623,6 +781,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
setShowAddCallbacksModal(false);
|
||||
setSelectedCallback(null);
|
||||
setSelectedCallbackParams([]);
|
||||
setAddAccess({});
|
||||
addForm.resetFields();
|
||||
}}
|
||||
disabled={isAddingCallback}
|
||||
|
|
@ -630,7 +789,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
Cancel
|
||||
</Button2>
|
||||
<Button2 htmlType="submit" loading={isAddingCallback} disabled={isAddingCallback}>
|
||||
{isAddingCallback ? "Adding..." : "Add Callback"}
|
||||
{isAddingCallback ? "Adding..." : "Add"}
|
||||
</Button2>
|
||||
</div>
|
||||
</Form>
|
||||
|
|
@ -701,13 +860,26 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
|
||||
<DeleteResourceModal
|
||||
isOpen={showDeleteConfirmModal}
|
||||
title="Delete Callback"
|
||||
message="Are you sure you want to delete this callback? This action cannot be undone."
|
||||
resourceInformationTitle="Callback Information"
|
||||
resourceInformation={[
|
||||
{ label: "Callback Name", value: callbackToDelete?.name },
|
||||
{ label: "Mode", value: callbackToDelete?.mode || "success" },
|
||||
]}
|
||||
title={callbackToDelete?.credentialName ? "Delete Destination" : "Delete Callback"}
|
||||
message={
|
||||
callbackToDelete?.credentialName
|
||||
? "Are you sure you want to delete this trace destination? Its stored collector credentials are deleted with it and traces stop reaching it. This action cannot be undone."
|
||||
: "Are you sure you want to delete this callback? This action cannot be undone."
|
||||
}
|
||||
resourceInformationTitle={callbackToDelete?.credentialName ? "Destination Information" : "Callback Information"}
|
||||
resourceInformation={
|
||||
// A destination has no mode. Defaulting the shared field to "success" stated a
|
||||
// value the row itself renders as "—", so the dialog disagreed with the table.
|
||||
callbackToDelete?.credentialName
|
||||
? [
|
||||
{ label: "Destination Name", value: callbackToDelete?.name },
|
||||
{ label: "Backend", value: callbackToDelete?.destinationLabel },
|
||||
]
|
||||
: [
|
||||
{ label: "Callback Name", value: callbackToDelete?.name },
|
||||
{ label: "Mode", value: callbackToDelete?.mode || "success" },
|
||||
]
|
||||
}
|
||||
onCancel={() => {
|
||||
setShowDeleteConfirmModal(false);
|
||||
setCallbackToDelete(null);
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
|
|||
|
||||
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
|
||||
useTeam: vi.fn(),
|
||||
useTeams: vi.fn().mockReturnValue({ data: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
|
||||
|
|
@ -956,6 +957,36 @@ describe("TeamInfoView", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("omits organization_id from an unrelated save so the org is not re-joined", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
vi.mocked(networking.teamInfoCall).mockResolvedValue(
|
||||
createMockTeamData({ organization_id: "org-123", models: ["gpt-4"] }),
|
||||
);
|
||||
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
|
||||
|
||||
renderWithProviders(<TeamInfoView {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: /edit settings/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Team Name")).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(networking.teamUpdateCall).toHaveBeenCalled();
|
||||
});
|
||||
const [, payload] = vi.mocked(networking.teamUpdateCall).mock.calls[0];
|
||||
expect(payload).not.toHaveProperty("organization_id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("metadata key-value editing", () => {
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ export interface TeamData {
|
|||
access_group_mcp_server_ids?: string[];
|
||||
access_group_agent_ids?: string[];
|
||||
access_group_details?: TeamAccessGroupModelGrant[];
|
||||
resolved_logging_exporters?: string[] | null;
|
||||
router_settings?: Record<string, any>;
|
||||
guardrails?: string[];
|
||||
policies?: string[];
|
||||
|
|
@ -247,6 +248,13 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
);
|
||||
|
||||
const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam || isTeamAdminFromTeamData;
|
||||
|
||||
// Destinations that will receive this team's traces, resolved server-side by
|
||||
// /team/info from credential_info.access. Names only, visible to every team viewer.
|
||||
const scopedExportersForTeam = useMemo<string[]>(
|
||||
() => teamData?.team_info?.resolved_logging_exporters ?? [],
|
||||
[teamData?.team_info],
|
||||
);
|
||||
const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]);
|
||||
const defaultTabKey = useMemo(() => getTeamInfoDefaultTab(editTeam, canEditTeam), [editTeam, canEditTeam]);
|
||||
|
||||
|
|
@ -884,6 +892,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
|
||||
<LoggingSettingsView
|
||||
loggingConfigs={info.metadata?.logging || []}
|
||||
scopedExporters={scopedExportersForTeam}
|
||||
disabledCallbacks={[]}
|
||||
variant="card"
|
||||
/>
|
||||
|
|
@ -1721,6 +1730,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
|
||||
<LoggingSettingsView
|
||||
loggingConfigs={info.metadata?.logging || []}
|
||||
scopedExporters={scopedExportersForTeam}
|
||||
disabledCallbacks={[]}
|
||||
variant="inline"
|
||||
className="pt-4 border-t border-gray-200"
|
||||
|
|
|
|||
|
|
@ -263,6 +263,16 @@ vi.mock("@/app/(dashboard)/hooks/keys/useResetKeySpend", () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
// KeyInfoView's Logging Exporters select pulls credentials + orgs via react-query;
|
||||
// mock both so this QueryClientProvider-free unit test of handleKeyUpdate runs.
|
||||
vi.mock("@/app/(dashboard)/hooks/credentials/useCredentials", () => ({
|
||||
useCredentials: vi.fn().mockReturnValue({ data: { credentials: [] }, refetch: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
|
||||
useOrganizations: vi.fn().mockReturnValue({ data: [], refetch: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/keys/useSetKeyBlockedState", () => ({
|
||||
useSetKeyBlockedState: vi.fn().mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -799,6 +799,7 @@ export function KeyEditView({
|
|||
<Input value={projectDisplay ?? ""} disabled />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item label="Logging Settings" name="logging_settings">
|
||||
<EditLoggingSettings
|
||||
value={form.getFieldValue("logging_settings")}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export default function KeyInfoView({
|
|||
const { mutate: setKeyBlockedState, isPending: blockLoading } = useSetKeyBlockedState();
|
||||
// Add local state to maintain key data and track regeneration
|
||||
const [currentKeyData, setCurrentKeyData] = useState<KeyResponse | undefined>(keyData);
|
||||
|
||||
const [lastRegeneratedAt, setLastRegeneratedAt] = useState<Date | null>(null);
|
||||
const [isRecentlyRegenerated, setIsRecentlyRegenerated] = useState(false);
|
||||
const [policyGuardrails, setPolicyGuardrails] = useState<Record<string, string[]>>({});
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ export const isProxyAdminRole = (role: string): boolean => {
|
|||
return role === "proxy_admin" || role === "Admin";
|
||||
};
|
||||
|
||||
// Roles allowed to read GET /credentials (proxy admin, plus the read-only admin viewer)
|
||||
export const canReadCredentialsRole = (role: string | null | undefined): boolean => {
|
||||
return role != null && (isProxyAdminRole(role) || role === "Admin Viewer" || role === "proxy_admin_viewer");
|
||||
};
|
||||
|
||||
export const isUserTeamAdminForAnyTeam = (teams: Team[] | null, userID: string): boolean => {
|
||||
if (teams == null) {
|
||||
return false;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue