mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
preview Impact
This commit is contained in:
parent
710e132201
commit
850f71135a
5 changed files with 275 additions and 44 deletions
|
|
@ -1,10 +1,12 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Modal, Form, Select, Radio, Divider, Typography } from "antd";
|
||||
import { Button } from "@tremor/react";
|
||||
import { Policy, PolicyAttachmentCreateRequest } from "./types";
|
||||
import { teamListCall, keyInfoCall, modelAvailableCall } from "../networking";
|
||||
import { Policy } from "./types";
|
||||
import { teamListCall, keyListCall, modelAvailableCall, estimateAttachmentImpactCall } from "../networking";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { buildAttachmentData } from "./build_attachment_data";
|
||||
import ImpactPreviewAlert from "./impact_preview_alert";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
|
|
@ -34,6 +36,8 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
|
|||
const [isLoadingTeams, setIsLoadingTeams] = useState(false);
|
||||
const [isLoadingKeys, setIsLoadingKeys] = useState(false);
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
||||
const [isEstimating, setIsEstimating] = useState(false);
|
||||
const [impactResult, setImpactResult] = useState<any>(null);
|
||||
const { userId, userRole } = useAuthorized();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -46,33 +50,30 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
|
|||
const loadTeamsKeysAndModels = async () => {
|
||||
if (!accessToken) return;
|
||||
|
||||
// Load teams
|
||||
// Load teams — teamListCall returns a plain array of team objects
|
||||
setIsLoadingTeams(true);
|
||||
try {
|
||||
// Pass null for organizationID since we're loading all teams the user has access to
|
||||
const teamsResponse = await teamListCall(accessToken, null, userId);
|
||||
if (teamsResponse?.data) {
|
||||
const teamAliases = teamsResponse.data
|
||||
.map((t: any) => t.team_alias)
|
||||
.filter(Boolean);
|
||||
setAvailableTeams(teamAliases);
|
||||
}
|
||||
const teamsArray = Array.isArray(teamsResponse) ? teamsResponse : (teamsResponse?.data || []);
|
||||
const teamAliases = teamsArray
|
||||
.map((t: any) => t.team_alias)
|
||||
.filter(Boolean);
|
||||
setAvailableTeams(teamAliases);
|
||||
} catch (error) {
|
||||
console.error("Failed to load teams:", error);
|
||||
} finally {
|
||||
setIsLoadingTeams(false);
|
||||
}
|
||||
|
||||
// Load keys
|
||||
// Load keys — keyListCall returns {keys: [...], total_count, ...}
|
||||
setIsLoadingKeys(true);
|
||||
try {
|
||||
const keysResponse = await keyInfoCall(accessToken, []);
|
||||
if (keysResponse?.data) {
|
||||
const keyAliases = keysResponse.data
|
||||
.map((k: any) => k.key_alias)
|
||||
.filter(Boolean);
|
||||
setAvailableKeys(keyAliases);
|
||||
}
|
||||
const keysResponse = await keyListCall(accessToken, null, null, null, null, null, 1, 100);
|
||||
const keysArray = keysResponse?.keys || keysResponse?.data || [];
|
||||
const keyAliases = keysArray
|
||||
.map((k: any) => k.key_alias)
|
||||
.filter(Boolean);
|
||||
setAvailableKeys(keyAliases);
|
||||
} catch (error) {
|
||||
console.error("Failed to load keys:", error);
|
||||
} finally {
|
||||
|
|
@ -83,12 +84,11 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
|
|||
setIsLoadingModels(true);
|
||||
try {
|
||||
const modelsResponse = await modelAvailableCall(accessToken, userId || "", userRole || "");
|
||||
if (modelsResponse?.data) {
|
||||
const modelIds = modelsResponse.data
|
||||
.map((m: any) => m.id || m.model_name)
|
||||
.filter(Boolean);
|
||||
setAvailableModels(modelIds);
|
||||
}
|
||||
const modelsArray = modelsResponse?.data || (Array.isArray(modelsResponse) ? modelsResponse : []);
|
||||
const modelIds = modelsArray
|
||||
.map((m: any) => m.id || m.model_name)
|
||||
.filter(Boolean);
|
||||
setAvailableModels(modelIds);
|
||||
} catch (error) {
|
||||
console.error("Failed to load models:", error);
|
||||
} finally {
|
||||
|
|
@ -99,6 +99,28 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
|
|||
const resetForm = () => {
|
||||
form.resetFields();
|
||||
setScopeType("global");
|
||||
setImpactResult(null);
|
||||
};
|
||||
|
||||
const getAttachmentData = () => buildAttachmentData(form.getFieldsValue(true), scopeType);
|
||||
|
||||
const handlePreviewImpact = async () => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
await form.validateFields(["policy_name"]);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setIsEstimating(true);
|
||||
try {
|
||||
const data = getAttachmentData();
|
||||
const result = await estimateAttachmentImpactCall(accessToken, data);
|
||||
setImpactResult(result);
|
||||
} catch (error) {
|
||||
console.error("Failed to estimate impact:", error);
|
||||
} finally {
|
||||
setIsEstimating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
|
|
@ -110,30 +132,12 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
|
|||
try {
|
||||
setIsSubmitting(true);
|
||||
await form.validateFields();
|
||||
const values = form.getFieldsValue(true);
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error("No access token available");
|
||||
}
|
||||
|
||||
const data: PolicyAttachmentCreateRequest = {
|
||||
policy_name: values.policy_name,
|
||||
};
|
||||
|
||||
if (scopeType === "global") {
|
||||
data.scope = "*";
|
||||
} else {
|
||||
if (values.teams && values.teams.length > 0) {
|
||||
data.teams = values.teams;
|
||||
}
|
||||
if (values.keys && values.keys.length > 0) {
|
||||
data.keys = values.keys;
|
||||
}
|
||||
if (values.models && values.models.length > 0) {
|
||||
data.models = values.models;
|
||||
}
|
||||
}
|
||||
|
||||
const data = getAttachmentData();
|
||||
await createAttachment(accessToken, data);
|
||||
NotificationsManager.success("Attachment created successfully");
|
||||
|
||||
|
|
@ -195,8 +199,8 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
|
|||
value={scopeType}
|
||||
onChange={(e) => setScopeType(e.target.value)}
|
||||
>
|
||||
<Radio value="specific">Specific (teams, keys, models, or tags)</Radio>
|
||||
<Radio value="global">Global (applies to all requests)</Radio>
|
||||
<Radio value="specific">Specific (teams, keys, or models)</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
|
|
@ -267,13 +271,41 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
|
|||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="tags"
|
||||
label="Tags"
|
||||
tooltip="Match against tags set in key or team metadata. Use exact values (e.g., healthcare) or wildcard patterns (e.g., health-*) where * matches any suffix."
|
||||
extra={
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Matches tags from key/team <code>metadata.tags</code> or tags passed dynamically in the request body. Use <code>*</code> as a suffix wildcard (e.g., <code>prod-*</code> matches <code>prod-us</code>, <code>prod-eu</code>).
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder="Type a tag and press Enter (e.g. healthcare, prod-*)"
|
||||
tokenSeparators={[",", " "]}
|
||||
notFoundContent={null}
|
||||
suffixIcon={null}
|
||||
open={false}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{impactResult && <ImpactPreviewAlert impactResult={impactResult} />}
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-4">
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{scopeType === "specific" && (
|
||||
<Button variant="secondary" onClick={handlePreviewImpact} loading={isEstimating}>
|
||||
Estimate Impact
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleSubmit} loading={isSubmitting}>
|
||||
Create Attachment
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -11,12 +11,14 @@ import {
|
|||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { PolicyAttachment } from "./types";
|
||||
import ImpactPopover from "./impact_popover";
|
||||
|
||||
interface AttachmentTableProps {
|
||||
attachments: PolicyAttachment[];
|
||||
isLoading: boolean;
|
||||
onDeleteClick: (attachmentId: string) => void;
|
||||
isAdmin: boolean;
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
const AttachmentTable: React.FC<AttachmentTableProps> = ({
|
||||
|
|
@ -24,6 +26,7 @@ const AttachmentTable: React.FC<AttachmentTableProps> = ({
|
|||
isLoading,
|
||||
onDeleteClick,
|
||||
isAdmin,
|
||||
accessToken,
|
||||
}) => {
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: "created_at", desc: true }]);
|
||||
|
||||
|
|
@ -152,6 +155,31 @@ const AttachmentTable: React.FC<AttachmentTableProps> = ({
|
|||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Tags",
|
||||
accessorKey: "tags",
|
||||
cell: ({ row }) => {
|
||||
const attachment = row.original;
|
||||
const tags = attachment.tags || [];
|
||||
if (tags.length === 0) {
|
||||
return <span className="text-xs text-gray-400">-</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.slice(0, 2).map((t, i) => (
|
||||
<Tag key={i} color="orange" className="text-xs">
|
||||
{t}
|
||||
</Tag>
|
||||
))}
|
||||
{tags.length > 2 && (
|
||||
<Tooltip title={tags.slice(2).join(", ")}>
|
||||
<Tag className="text-xs">+{tags.length - 2}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Created At",
|
||||
accessorKey: "created_at",
|
||||
|
|
@ -171,6 +199,7 @@ const AttachmentTable: React.FC<AttachmentTableProps> = ({
|
|||
const attachment = row.original;
|
||||
return (
|
||||
<div className="flex space-x-2">
|
||||
<ImpactPopover attachment={attachment} accessToken={accessToken} />
|
||||
{isAdmin && (
|
||||
<Tooltip title="Delete attachment">
|
||||
<Icon
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import { PolicyAttachmentCreateRequest } from "./types";
|
||||
|
||||
/**
|
||||
* Builds a PolicyAttachmentCreateRequest from form values.
|
||||
*
|
||||
* @param formValues - The raw form field values (from form.getFieldsValue)
|
||||
* @param scopeType - Whether the attachment is "global" or "specific"
|
||||
*/
|
||||
export function buildAttachmentData(
|
||||
formValues: Record<string, any>,
|
||||
scopeType: "global" | "specific"
|
||||
): PolicyAttachmentCreateRequest {
|
||||
const data: PolicyAttachmentCreateRequest = {
|
||||
policy_name: formValues.policy_name,
|
||||
};
|
||||
if (scopeType === "global") {
|
||||
data.scope = "*";
|
||||
} else {
|
||||
if (formValues.teams && formValues.teams.length > 0) data.teams = formValues.teams;
|
||||
if (formValues.keys && formValues.keys.length > 0) data.keys = formValues.keys;
|
||||
if (formValues.models && formValues.models.length > 0) data.models = formValues.models;
|
||||
if (formValues.tags && formValues.tags.length > 0) data.tags = formValues.tags;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import React, { useState } from "react";
|
||||
import { Icon } from "@tremor/react";
|
||||
import { EyeIcon } from "@heroicons/react/outline";
|
||||
import { Tooltip, Tag, Popover, Spin } from "antd";
|
||||
import { PolicyAttachment } from "./types";
|
||||
import { estimateAttachmentImpactCall } from "../networking";
|
||||
|
||||
const ImpactPopover: React.FC<{ attachment: PolicyAttachment; accessToken: string | null }> = ({
|
||||
attachment,
|
||||
accessToken,
|
||||
}) => {
|
||||
const [impact, setImpact] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const loadImpact = async () => {
|
||||
if (loaded || loading || !accessToken) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await estimateAttachmentImpactCall(accessToken, {
|
||||
policy_name: attachment.policy_name,
|
||||
scope: attachment.scope,
|
||||
teams: attachment.teams,
|
||||
keys: attachment.keys,
|
||||
models: attachment.models,
|
||||
tags: attachment.tags,
|
||||
});
|
||||
setImpact(data);
|
||||
setLoaded(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to load impact:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const content = loading ? (
|
||||
<div className="p-2 text-center"><Spin size="small" /> Loading...</div>
|
||||
) : impact ? (
|
||||
<div className="text-xs" style={{ maxWidth: 280 }}>
|
||||
{impact.affected_keys_count === -1 ? (
|
||||
<p className="font-medium text-amber-600">Global scope — affects all keys and teams</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-1">
|
||||
<strong>{impact.affected_keys_count}</strong> key{impact.affected_keys_count !== 1 ? "s" : ""},{" "}
|
||||
<strong>{impact.affected_teams_count}</strong> team{impact.affected_teams_count !== 1 ? "s" : ""} affected
|
||||
</p>
|
||||
{impact.sample_keys.length > 0 && (
|
||||
<div className="mb-1">
|
||||
<span className="text-gray-500">Keys: </span>
|
||||
{impact.sample_keys.map((k: string) => (
|
||||
<Tag key={k} style={{ fontSize: 10, margin: 1 }}>{k}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{impact.sample_teams.length > 0 && (
|
||||
<div>
|
||||
<span className="text-gray-500">Teams: </span>
|
||||
{impact.sample_teams.map((t: string) => (
|
||||
<Tag key={t} style={{ fontSize: 10, margin: 1 }}>{t}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{impact.affected_keys_count === 0 && impact.affected_teams_count === 0 && (
|
||||
<p className="text-gray-400">No keys or teams currently affected</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">Click to load</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} title="Blast Radius" trigger="click" onOpenChange={(open) => { if (open) loadImpact(); }}>
|
||||
<Tooltip title="View blast radius">
|
||||
<Icon icon={EyeIcon} size="sm" className="cursor-pointer hover:text-blue-500" />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImpactPopover;
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import React from "react";
|
||||
import { Alert, Tag, Typography } from "antd";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface ImpactResult {
|
||||
affected_keys_count: number;
|
||||
affected_teams_count: number;
|
||||
sample_keys: string[];
|
||||
sample_teams: string[];
|
||||
}
|
||||
|
||||
interface ImpactPreviewAlertProps {
|
||||
impactResult: ImpactResult;
|
||||
}
|
||||
|
||||
const ImpactPreviewAlert: React.FC<ImpactPreviewAlertProps> = ({ impactResult }) => {
|
||||
return (
|
||||
<Alert
|
||||
type={impactResult.affected_keys_count === -1 ? "warning" : "info"}
|
||||
showIcon
|
||||
className="mb-4"
|
||||
message="Impact Preview"
|
||||
description={
|
||||
impactResult.affected_keys_count === -1 ? (
|
||||
<Text>Global scope — this will affect <strong>all keys and teams</strong>.</Text>
|
||||
) : (
|
||||
<div>
|
||||
<Text>
|
||||
This attachment would affect <strong>{impactResult.affected_keys_count} key{impactResult.affected_keys_count !== 1 ? "s" : ""}</strong> and <strong>{impactResult.affected_teams_count} team{impactResult.affected_teams_count !== 1 ? "s" : ""}</strong>.
|
||||
</Text>
|
||||
{impactResult.sample_keys.length > 0 && (
|
||||
<div className="mt-1">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>Keys: </Text>
|
||||
{impactResult.sample_keys.slice(0, 5).map((k: string) => (
|
||||
<Tag key={k} style={{ fontSize: 11 }}>{k}</Tag>
|
||||
))}
|
||||
{impactResult.affected_keys_count > 5 && (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>and {impactResult.affected_keys_count - 5} more...</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{impactResult.sample_teams.length > 0 && (
|
||||
<div className="mt-1">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>Teams: </Text>
|
||||
{impactResult.sample_teams.slice(0, 5).map((t: string) => (
|
||||
<Tag key={t} style={{ fontSize: 11 }}>{t}</Tag>
|
||||
))}
|
||||
{impactResult.affected_teams_count > 5 && (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>and {impactResult.affected_teams_count - 5} more...</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImpactPreviewAlert;
|
||||
Loading…
Add table
Reference in a new issue