refactor(ui): move the agent, guardrail, prompt, policy and skill forms off tremor (#37320)

* refactor(ui): move the agent, guardrail, prompt, policy and skill forms off tremor

The agent info Save Changes button used to rely on tremor's implicit
submit inside the antd Form, so it now carries an explicit type="submit".
Every converted TabsContent is keepMounted to keep tremor's always-mounted
panel semantics, pinned by a new guardrail info test. Prunes the tremor
no-restricted-imports suppressions these nine files no longer need.

* fix(ui): keep the line tab strip on the agent, guardrail and prompt info views

tremor's TabList defaulted to the line variant while shadcn's TabsList
defaults to the filled pill, so the bare conversion turned three underlined
tab strips into segmented pills. Restores the line variant plus the bottom
border and the tab padding the strips used to have.

* test(ui): pin the agent settings submit and the prompt raw json tab

Agent Settings only saves because Save Changes carries an explicit
type="submit" now that the button is a Base UI button, so a test drives the
edit and asserts the patch call fires. The prompt info tabs are keyed by slug
now, which also makes Raw JSON render for prompts with no template, so a
second test renders that case and asserts the serialized response is visible.
This commit is contained in:
ryan-crabbe-berri 2026-08-18 16:40:44 -07:00 committed by GitHub
parent 589f6859d8
commit 669c1334b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 373 additions and 303 deletions

View file

@ -4,11 +4,6 @@
"count": 1
}
},
"src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.tsx": {
"no-restricted-imports": {
"count": 1
@ -33,7 +28,7 @@
"count": 3
},
"no-restricted-imports": {
"count": 2
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 2
@ -58,12 +53,6 @@
"src/app/(dashboard)/agents/_components/agent_form_fields.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/agents/_components/agent_info.tsx": {
@ -73,11 +62,8 @@
"local/no-complex-jsx-arrow": {
"count": 1
},
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 2
"count": 1
},
"react-hooks/immutability": {
"count": 1
@ -94,20 +80,11 @@
"src/app/(dashboard)/agents/_components/cost_config_fields.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/agents/_components/dynamic_agent_form_fields.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-nested-ternary": {
"count": 2
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/budgets/_components/budget_modal.tsx": {
@ -115,7 +92,7 @@
"count": 1
},
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"src/app/(dashboard)/budgets/_components/budget_panel.tsx": {
@ -128,7 +105,7 @@
"count": 1
},
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"src/app/(dashboard)/caching/_components/cache_dashboard.tsx": {
@ -147,16 +124,6 @@
"count": 1
}
},
"src/app/(dashboard)/caching/_components/cache_settings/CacheFormField.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsFields.ts": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/caching/_components/cache_settings/index.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -165,22 +132,9 @@
"count": 1
}
},
"src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisFormField.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/caching/_components/coordination_redis_settings/coordinationRedisFields.ts": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/caching/_components/coordination_redis_settings/index.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/caching/_components/response_time_indicator.tsx": {
@ -383,7 +337,7 @@
"count": 1
},
"no-restricted-imports": {
"count": 2
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 3
@ -1204,18 +1158,7 @@
"count": 1
}
},
"src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.test.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx": {
"local/no-complex-jsx-arrow": {
"count": 1
},
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 2
}
@ -1286,7 +1229,7 @@
"count": 3
},
"no-restricted-imports": {
"count": 2
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 2
@ -1354,12 +1297,12 @@
"count": 1
},
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
}
},
"src/app/(dashboard)/tag-management/_components/index.tsx": {
@ -1475,9 +1418,6 @@
"local/filename-pascal-case": {
"count": 1
},
"local/no-complex-jsx-arrow": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
@ -1645,11 +1585,6 @@
"count": 1
}
},
"src/components/SSOModals.test.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/SSOModals.tsx": {
"no-restricted-imports": {
"count": 1
@ -1691,11 +1626,6 @@
"count": 1
}
},
"src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.test.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx": {
"max-nested-callbacks": {
"count": 1
@ -2098,14 +2028,6 @@
"count": 1
}
},
"src/components/common_components/KeyLifecycleSettings.tsx": {
"local/no-complex-jsx-arrow": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/MetadataKeyValueFields.test.tsx": {
"no-restricted-imports": {
"count": 1
@ -2136,11 +2058,6 @@
"count": 1
}
},
"src/components/common_components/RateLimitTypeFormItem.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/budget_duration_dropdown.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -3150,4 +3067,4 @@
"count": 1
}
}
}
}

View file

@ -3,10 +3,10 @@ import { Modal, Select, Steps, Tag } from "antd";
import { FormProvider, useForm, useWatch } from "react-hook-form";
import { toast } from "@/lib/toast";
import { Logo } from "@/components/molecules/logo/Logo";
import { Button } from "@tremor/react";
import { CheckCircleFilled, KeyOutlined, RobotOutlined, AppstoreOutlined } from "@ant-design/icons";
import CreatedKeyDisplay from "@/components/shared/CreatedKeyDisplay";
import { Button as ShadButton } from "@/components/ui/button";
import { Button } from "@/components/ui/button";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Separator } from "@/components/ui/separator";
@ -1025,9 +1025,9 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
<div className="mt-6 flex items-center justify-between border-t border-border pt-6">
<div>
{currentStep > 0 && currentStep < 4 && (
<ShadButton type="button" variant="outline" onClick={handleBack}>
<Button type="button" variant="outline" onClick={handleBack}>
Back
</ShadButton>
</Button>
)}
</div>
<div className="flex gap-3">
@ -1036,21 +1036,14 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
Cancel
</Button>
)}
{currentStep < 3 && (
<Button variant="primary" onClick={handleNext}>
Next
</Button>
)}
{currentStep < 3 && <Button onClick={handleNext}>Next </Button>}
{currentStep === 3 && (
<Button variant="primary" loading={isSubmitting} onClick={handleCreateAgent}>
<Button disabled={isSubmitting} aria-busy={isSubmitting} onClick={handleCreateAgent}>
{isSubmitting && <UiLoadingSpinner className="size-4" />}
{isSubmitting ? "Creating..." : "Create Agent →"}
</Button>
)}
{currentStep === 4 && (
<Button variant="primary" onClick={handleClose}>
Done
</Button>
)}
{currentStep === 4 && <Button onClick={handleClose}>Done</Button>}
</div>
</div>
</div>

View file

@ -0,0 +1,66 @@
import React from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import AgentInfoView from "./agent_info";
import * as networking from "@/components/networking";
import type { Agent } from "@/components/agents/types";
vi.mock("@/components/networking", () => ({
getAgentInfo: vi.fn(),
getAgentCreateMetadata: vi.fn(),
patchAgentCall: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({
useKeys: () => ({ data: { keys: [] }, isLoading: false, refetch: vi.fn() }),
}));
vi.mock("./agent_card_discovery", () => ({
default: () => <div data-testid="agent-card-discovery" />,
}));
vi.mock("./agent_form_fields", () => ({
default: () => <div data-testid="agent-form-fields" />,
unmountedA2AFieldNames: () => [],
}));
const agent = {
agent_id: "agent-1",
agent_name: "support-agent",
agent_card_params: {
name: "Support Agent",
description: "Answers support questions",
url: "http://localhost:9999/",
version: "1.0.0",
protocolVersion: "1.0",
capabilities: { streaming: false },
skills: [],
},
tpm_limit: 100,
} as unknown as Agent;
describe("AgentInfoView settings", () => {
beforeEach(() => {
vi.mocked(networking.getAgentInfo).mockReset().mockResolvedValue(agent);
vi.mocked(networking.getAgentCreateMetadata).mockReset().mockResolvedValue([]);
vi.mocked(networking.patchAgentCall).mockReset().mockResolvedValue({});
});
it("submits the edited agent when Save Changes is pressed", async () => {
render(<AgentInfoView agentId="agent-1" onClose={vi.fn()} accessToken="sk-test" isAdmin={true} />);
fireEvent.click(await screen.findByRole("tab", { name: "Settings" }));
fireEvent.click(screen.getByRole("button", { name: "Edit Settings" }));
const tpmLimit = await screen.findByLabelText("TPM Limit");
fireEvent.change(tpmLimit, { target: { value: "42" } });
fireEvent.click(screen.getByRole("button", { name: /Save Changes/ }));
await waitFor(() => expect(networking.patchAgentCall).toHaveBeenCalledTimes(1));
const [token, agentId, payload] = vi.mocked(networking.patchAgentCall).mock.calls[0];
expect(token).toBe("sk-test");
expect(agentId).toBe("agent-1");
expect(payload.tpm_limit).toBe(42);
});
});

View file

@ -1,15 +1,16 @@
import React, { useState, useEffect, useMemo } from "react";
import { Card, Title, Text, Button as TremorButton, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
import { Spin, Descriptions } from "antd";
import { FormProvider, useForm, useWatch } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TooltipProvider } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { Field, FieldGroup, FieldLabel } from "@/components/shared/form/field";
import { toast } from "@/lib/toast";
import { ArrowLeftIcon } from "@heroicons/react/outline";
import { ArrowLeft } from "lucide-react";
import { getAgentInfo, patchAgentCall, getAgentCreateMetadata, AgentCreateInfo } from "@/components/networking";
import { Agent } from "@/components/agents/types";
import { KeyResponse } from "@/components/key_team_helpers/key_list";
@ -46,6 +47,7 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
const agentKeys = keysData?.keys ?? [];
const [isLoading, setIsLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [activeTab, setActiveTab] = useState("overview");
const [isSaving, setIsSaving] = useState(false);
const form = useForm<AgentFormValues>({ defaultValues: {} });
const panels = useCollapsiblePanels([AGENT_FORM_CONFIG.basic.key]);
@ -203,9 +205,9 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
return (
<div className="p-4">
<div className="text-center">Agent not found</div>
<TremorButton onClick={onClose} className="mt-4">
<Button onClick={onClose} className="mt-4">
Back to Agents List
</TremorButton>
</Button>
</div>
);
}
@ -251,22 +253,29 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
return (
<div className="p-4">
<div>
<TremorButton icon={ArrowLeftIcon} variant="light" onClick={onClose} className="mb-4">
<Button variant="ghost" onClick={onClose} className="mb-4">
<ArrowLeft className="size-4" />
Back to Agents
</TremorButton>
<Title>{agent.agent_name || "Unnamed Agent"}</Title>
<Text className="text-gray-500 font-mono">{agent.agent_id}</Text>
</Button>
<h1 className="text-2xl font-semibold">{agent.agent_name || "Unnamed Agent"}</h1>
<p className="text-sm text-gray-500 font-mono">{agent.agent_id}</p>
</div>
<TabGroup>
<TabList className="mb-4">
<Tab key="overview">Overview</Tab>
{isAdmin ? <Tab key="settings">Settings</Tab> : <></>}
</TabList>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList variant="line" className="mb-4 h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="overview" className="flex-none rounded-none px-4 py-2">
Overview
</TabsTrigger>
{isAdmin && (
<TabsTrigger value="settings" className="flex-none rounded-none px-4 py-2">
Settings
</TabsTrigger>
)}
</TabsList>
<TabPanels>
<div>
{/* Overview Panel */}
<TabPanel>
<TabsContent value="overview" keepMounted>
<Descriptions bordered column={1}>
<Descriptions.Item label="Agent ID">{agent.agent_id}</Descriptions.Item>
<Descriptions.Item label="Agent Name">{agent.agent_name}</Descriptions.Item>
@ -321,7 +330,7 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
(agent.object_permission.mcp_tool_permissions &&
Object.keys(agent.object_permission.mcp_tool_permissions).length > 0)) && (
<div style={{ marginTop: 24 }}>
<Title>MCP Tool Permissions</Title>
<h3 className="text-lg font-medium">MCP Tool Permissions</h3>
<Descriptions bordered column={1} style={{ marginTop: 16 }}>
{agent.object_permission.mcp_servers && agent.object_permission.mcp_servers.length > 0 && (
<Descriptions.Item label="MCP Servers">
@ -355,7 +364,7 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
{agent.agent_card_params?.skills && agent.agent_card_params.skills.length > 0 && (
<div style={{ marginTop: 24 }}>
<Title>Skills</Title>
<h3 className="text-lg font-medium">Skills</h3>
<Descriptions bordered column={1} style={{ marginTop: 16 }}>
{agent.agent_card_params.skills.map((skill: any, index: number) => (
<Descriptions.Item label={skill.name || `Skill ${index + 1}`} key={index}>
@ -381,23 +390,23 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
</Descriptions>
</div>
)}
</TabPanel>
</TabsContent>
{/* Settings Panel (only for admins) */}
{isAdmin && (
<TabPanel>
<Card>
<TabsContent value="settings" keepMounted>
<Card className="block p-6">
<div className="flex justify-between items-center mb-4">
<Title>Agent Settings</Title>
<h3 className="text-lg font-medium">Agent Settings</h3>
{!isEditing && (
<TremorButton
<Button
onClick={() => {
setAppliedDiscoveredSelection(null);
setIsEditing(true);
}}
>
Edit Settings
</TremorButton>
</Button>
)}
</div>
@ -430,7 +439,7 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
)}
<Separator className="my-6" />
<Title className="mb-4">Rate Limits</Title>
<h3 className="text-lg font-medium mb-4">Rate Limits</h3>
<div className="grid grid-cols-2 gap-4">
{rateLimitField("tpm_limit", "TPM Limit")}
{rateLimitField("rpm_limit", "RPM Limit")}
@ -452,8 +461,8 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
>
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving && <UiLoadingSpinner />}
<Button type="submit" disabled={isSaving} aria-busy={isSaving}>
{isSaving && <UiLoadingSpinner className="size-4" />}
Save Changes
</Button>
</div>
@ -461,13 +470,13 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
</FormProvider>
</TooltipProvider>
) : (
<Text>Click &quot;Edit Settings&quot; to modify agent configuration.</Text>
<p>Click &quot;Edit Settings&quot; to modify agent configuration.</p>
)}
</Card>
</TabPanel>
</TabsContent>
)}
</TabPanels>
</TabGroup>
</div>
</Tabs>
</div>
);
};

View file

@ -299,4 +299,30 @@ describe("Guardrail Info", () => {
expect(secondCallArgs.litellm_params.patterns).toEqual(["new_pattern"]);
expect(secondCallArgs.litellm_params.blocked_words).toEqual(["new_word"]);
});
it("keeps the settings panel mounted while the overview tab is active", async () => {
vi.mocked(networking.getGuardrailInfo).mockResolvedValue({
guardrail_id: "123",
guardrail_name: "Test Guardrail",
litellm_params: { guardrail: "presidio", mode: "pre_call", default_on: true },
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
guardrail_definition_location: "database",
});
vi.mocked(networking.getGuardrailUISettings).mockResolvedValue({
supported_entities: [],
supported_actions: [],
pii_entity_categories: [],
supported_modes: ["pre_call"],
});
vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({});
const { findByRole, getByRole, getByText } = render(
<GuardrailInfoView guardrailId="123" onClose={() => {}} accessToken="123" isAdmin={true} />,
);
expect(await findByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true");
expect(getByRole("tab", { name: "Settings" })).toHaveAttribute("aria-selected", "false");
expect(getByText("Guardrail Settings")).toBeInTheDocument();
});
});

View file

@ -6,10 +6,11 @@ import {
} from "@/components/networking";
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
import { CodeOutlined, EyeInvisibleOutlined, InfoCircleOutlined, StopOutlined } from "@ant-design/icons";
import { ArrowLeftIcon } from "@heroicons/react/outline";
import { Badge, Card, Grid, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react";
import { Button as AntdButton, Tooltip } from "antd";
import { CheckIcon, CopyIcon } from "lucide-react";
import { ArrowLeft, CheckIcon, CopyIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import React, { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "@/lib/toast";
@ -509,12 +510,12 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
return (
<div className="p-4">
<div>
<AntdButton type="text" icon={<ArrowLeftIcon className="w-4 h-4" />} onClick={onClose} className="mb-4">
<AntdButton type="text" icon={<ArrowLeft className="w-4 h-4" />} onClick={onClose} className="mb-4">
Back to Guardrails
</AntdButton>
<Title>{guardrailData.guardrail_name || "Unnamed Guardrail"}</Title>
<h1 className="text-2xl font-semibold">{guardrailData.guardrail_name || "Unnamed Guardrail"}</h1>
<div className="flex items-center cursor-pointer">
<Text className="text-muted-foreground font-mono">{guardrailData.guardrail_id}</Text>
<p className="text-muted-foreground font-mono">{guardrailData.guardrail_id}</p>
<AntdButton
type="text"
@ -530,49 +531,55 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
</div>
</div>
<TabGroup>
<TabList className="mb-4">
<Tab key="overview">Overview</Tab>
{isAdmin ? <Tab key="settings">Settings</Tab> : <></>}
</TabList>
<Tabs defaultValue="overview">
<TabsList variant="line" className="mb-4 h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="overview" className="flex-none rounded-none px-4 py-2">
Overview
</TabsTrigger>
{isAdmin && (
<TabsTrigger value="settings" className="flex-none rounded-none px-4 py-2">
Settings
</TabsTrigger>
)}
</TabsList>
<TabPanels>
<div>
{/* Overview Panel */}
<TabPanel>
<Grid numItems={1} numItemsSm={2} numItemsLg={3} className="gap-6">
<Card>
<Text>Provider</Text>
<TabsContent value="overview" keepMounted>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<Card className="block p-6">
<p>Provider</p>
<div className="mt-2 flex items-center space-x-2">
<Logo src={logo} label={displayName} className="w-6 h-6" />
<Title>{displayName}</Title>
<h3 className="text-lg font-medium">{displayName}</h3>
</div>
</Card>
<Card>
<Text>Mode</Text>
<Card className="block p-6">
<p>Mode</p>
<div className="mt-2">
<Title>{guardrailData.litellm_params?.mode || "-"}</Title>
<Badge color={guardrailData.litellm_params?.default_on ? "green" : "gray"}>
<h3 className="text-lg font-medium">{guardrailData.litellm_params?.mode || "-"}</h3>
<Badge variant={guardrailData.litellm_params?.default_on ? "secondary" : "outline"}>
{guardrailData.litellm_params?.default_on ? "Default On" : "Default Off"}
</Badge>
</div>
</Card>
<Card>
<Text>Created At</Text>
<Card className="block p-6">
<p>Created At</p>
<div className="mt-2">
<Title>{formatDate(guardrailData.created_at)}</Title>
<Text>Last Updated: {formatDate(guardrailData.updated_at)}</Text>
<h3 className="text-lg font-medium">{formatDate(guardrailData.created_at)}</h3>
<p>Last Updated: {formatDate(guardrailData.updated_at)}</p>
</div>
</Card>
</Grid>
</div>
{guardrailData.litellm_params?.pii_entities_config &&
Object.keys(guardrailData.litellm_params.pii_entities_config).length > 0 && (
<Card className="mt-6">
<Card className="block mt-6 p-6">
<div className="flex justify-between items-center">
<Text className="font-medium">PII Protection</Text>
<Badge color="blue">
<p className="font-medium">PII Protection</p>
<Badge variant="secondary">
{Object.keys(guardrailData.litellm_params.pii_entities_config).length} PII entities configured
</Badge>
</div>
@ -581,18 +588,18 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
{guardrailData.litellm_params?.pii_entities_config &&
Object.keys(guardrailData.litellm_params.pii_entities_config).length > 0 && (
<Card className="mt-6">
<Text className="mb-4 text-lg font-semibold">PII Entity Configuration</Text>
<Card className="block mt-6 p-6">
<p className="mb-4 text-lg font-semibold">PII Entity Configuration</p>
<div className="border rounded-lg overflow-hidden shadow-xs">
<div className="bg-muted px-5 py-3 border-b flex">
<Text className="flex-1 font-semibold text-foreground">Entity Type</Text>
<Text className="flex-1 font-semibold text-foreground">Configuration</Text>
<p className="flex-1 font-semibold text-foreground">Entity Type</p>
<p className="flex-1 font-semibold text-foreground">Configuration</p>
</div>
<div className="max-h-[400px] overflow-y-auto">
{Object.entries(guardrailData.litellm_params?.pii_entities_config).map(([key, value]) => (
<div key={key} className="px-5 py-3 flex border-b hover:bg-muted/50 transition-colors">
<Text className="flex-1 font-medium text-foreground">{key}</Text>
<Text className="flex-1">
<p className="flex-1 font-medium text-foreground">{key}</p>
<p className="flex-1">
<span
className={`inline-flex items-center gap-1.5 ${
value === "MASK" ? "text-blue-600" : "text-red-600"
@ -601,7 +608,7 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
{value === "MASK" ? <EyeInvisibleOutlined /> : <StopOutlined />}
{String(value)}
</span>
</Text>
</p>
</div>
))}
</div>
@ -610,18 +617,18 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
)}
{guardrailData.litellm_params?.guardrail === "tool_permission" && (
<Card className="mt-6">
<Card className="block mt-6 p-6">
<ToolPermissionRulesEditor value={toolPermissionConfig} disabled />
</Card>
)}
{/* Custom Code Display */}
{guardrailData.litellm_params?.guardrail === "custom_code" && guardrailData.litellm_params?.custom_code && (
<Card className="mt-6">
<Card className="block mt-6 p-6">
<div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-2">
<CodeOutlined className="text-blue-500" />
<Text className="font-medium text-lg">Custom Code</Text>
<p className="font-medium text-lg">Custom Code</p>
</div>
{isAdmin && !isConfigGuardrail && (
<AntdButton size="small" icon={<CodeOutlined />} onClick={() => setCustomCodeModalVisible(true)}>
@ -647,14 +654,14 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
isEditing={false}
accessToken={accessToken}
/>
</TabPanel>
</TabsContent>
{/* Settings Panel (only for admins) */}
{isAdmin && (
<TabPanel>
<Card>
<TabsContent value="settings" keepMounted>
<Card className="block p-6">
<div className="flex justify-between items-center mb-4">
<Title>Guardrail Settings</Title>
<h3 className="text-lg font-medium">Guardrail Settings</h3>
{isConfigGuardrail && (
<Tooltip title="Guardrail is defined in the config file and cannot be edited.">
<InfoCircleOutlined />
@ -831,24 +838,24 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
) : (
<div className="space-y-4">
<div>
<Text className="font-medium">Guardrail ID</Text>
<p className="font-medium">Guardrail ID</p>
<div className="font-mono">{guardrailData.guardrail_id}</div>
</div>
<div>
<Text className="font-medium">Guardrail Name</Text>
<p className="font-medium">Guardrail Name</p>
<div>{guardrailData.guardrail_name || "Unnamed Guardrail"}</div>
</div>
<div>
<Text className="font-medium">Provider</Text>
<p className="font-medium">Provider</p>
<div>{displayName}</div>
</div>
<div>
<Text className="font-medium">Mode</Text>
<p className="font-medium">Mode</p>
<div>{guardrailData.litellm_params?.mode || "-"}</div>
</div>
<div>
<Text className="font-medium">Default On</Text>
<Badge color={guardrailData.litellm_params?.default_on ? "green" : "gray"}>
<p className="font-medium">Default On</p>
<Badge variant={guardrailData.litellm_params?.default_on ? "secondary" : "outline"}>
{guardrailData.litellm_params?.default_on ? "Yes" : "No"}
</Badge>
</div>
@ -856,9 +863,9 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
{guardrailData.litellm_params?.pii_entities_config &&
Object.keys(guardrailData.litellm_params.pii_entities_config).length > 0 && (
<div>
<Text className="font-medium">PII Protection</Text>
<p className="font-medium">PII Protection</p>
<div className="mt-2">
<Badge color="blue">
<Badge variant="secondary">
{Object.keys(guardrailData.litellm_params.pii_entities_config).length} PII entities
configured
</Badge>
@ -867,11 +874,11 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
)}
<div>
<Text className="font-medium">Created At</Text>
<p className="font-medium">Created At</p>
<div>{formatDate(guardrailData.created_at)}</div>
</div>
<div>
<Text className="font-medium">Last Updated</Text>
<p className="font-medium">Last Updated</p>
<div>{formatDate(guardrailData.updated_at)}</div>
</div>
@ -881,10 +888,10 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
</div>
)}
</Card>
</TabPanel>
</TabsContent>
)}
</TabPanels>
</TabGroup>
</div>
</Tabs>
{/* Custom Code Editor Modal */}
<CustomCodeModal

View file

@ -417,12 +417,23 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
Cancel
</Button>
{scopeType === "specific" && (
<Button type="button" variant="secondary" onClick={handlePreviewImpact} disabled={isEstimating}>
<Button
type="button"
variant="secondary"
onClick={handlePreviewImpact}
disabled={isEstimating}
aria-busy={isEstimating}
>
{isEstimating && <UiLoadingSpinner className="size-4" />}
Estimate Impact
</Button>
)}
<Button type="button" onClick={form.handleSubmit(handleSubmit)} disabled={isSubmitting}>
<Button
type="button"
onClick={form.handleSubmit(handleSubmit)}
disabled={isSubmitting}
aria-busy={isSubmitting}
>
{isSubmitting && <UiLoadingSpinner className="size-4" />}
Create Attachment
</Button>

View file

@ -546,7 +546,12 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button type="button" onClick={form.handleSubmit(handleSubmit)} disabled={isSubmitting}>
<Button
type="button"
onClick={form.handleSubmit(handleSubmit)}
disabled={isSubmitting}
aria-busy={isSubmitting}
>
{isSubmitting && <UiLoadingSpinner className="size-4" />}
{isEditing ? "Update Policy" : "Create Policy"}
</Button>

View file

@ -218,7 +218,7 @@ const PolicyTestPanel: React.FC<PolicyTestPanelProps> = ({ accessToken }) => {
</FormField>
</FieldGroup>
<div className="flex space-x-2 mt-4">
<Button type="button" onClick={handleTest} disabled={isLoading || !accessToken}>
<Button type="button" onClick={handleTest} disabled={isLoading || !accessToken} aria-busy={isLoading}>
{isLoading && <UiLoadingSpinner className="size-4" />}
Simulate
</Button>

View file

@ -0,0 +1,50 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import PromptInfoView from "./prompt_info";
import * as networking from "@/components/networking";
vi.mock("@/components/networking", () => ({
getPromptInfo: vi.fn(),
getPromptVersions: vi.fn(),
deletePromptCall: vi.fn(),
}));
vi.mock("./prompt_editor_view/PromptCodeSnippets", () => ({
default: () => <div data-testid="prompt-code-snippets" />,
}));
const promptWithoutTemplate = {
prompt_spec: {
prompt_id: "support-reply",
version: 1,
litellm_params: { prompt_id: "support-reply" },
prompt_info: { prompt_type: "dotprompt" },
created_by: "admin",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
raw_prompt_template: null,
environments: [],
};
describe("PromptInfoView tabs", () => {
beforeEach(() => {
vi.mocked(networking.getPromptInfo).mockReset().mockResolvedValue(promptWithoutTemplate);
vi.mocked(networking.getPromptVersions).mockReset().mockResolvedValue({ prompts: [] });
});
it("shows the raw API response for a prompt that has no template", async () => {
const user = userEvent.setup();
render(<PromptInfoView promptId="support-reply" onClose={vi.fn()} accessToken="sk-test" isAdmin={true} />);
expect(await screen.findByRole("tab", { name: "Raw JSON" })).toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "Prompt Template" })).not.toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "Raw JSON" }));
expect(screen.getByText("Raw API Response")).toBeVisible();
expect(screen.getByText(/"prompt_id": "support-reply"/)).toBeVisible();
});
});

View file

@ -1,25 +1,10 @@
import React, { useState, useEffect, useRef } from "react";
import {
Card,
Title,
Text,
Grid,
Badge,
Button as TremorButton,
Tab,
TabGroup,
TabList,
TabPanel,
TabPanels,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
} from "@tremor/react";
import { Button, Modal } from "antd";
import { ArrowLeftIcon, TrashIcon, PencilIcon } from "@heroicons/react/outline";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button as AntButton, Modal } from "antd";
import {
getPromptInfo,
getPromptVersions,
@ -28,7 +13,7 @@ import {
deletePromptCall,
} from "@/components/networking";
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
import { CheckIcon, CopyIcon } from "lucide-react";
import { ArrowLeft, CheckIcon, CopyIcon, Pencil, Trash2 } from "lucide-react";
import { toast } from "@/lib/toast";
import PromptCodeSnippets from "./prompt_editor_view/PromptCodeSnippets";
import { extractModel, extractTemplateVariables, getBasePromptId, getCurrentVersion } from "./prompt_utils";
@ -197,15 +182,16 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
return (
<div className="p-4">
<div>
<TremorButton icon={ArrowLeftIcon} variant="light" onClick={onClose} className="mb-4">
<Button variant="ghost" onClick={onClose} className="mb-4">
<ArrowLeft className="size-4" />
Back to Prompts
</TremorButton>
</Button>
<div className="flex justify-between items-start mb-4">
<div>
<Title>Prompt Details</Title>
<h1 className="text-2xl font-semibold">Prompt Details</h1>
<div className="flex items-center cursor-pointer">
<Text className="text-gray-500 font-mono">{basePromptId}</Text>
<Button
<p className="text-sm text-gray-500 font-mono">{basePromptId}</p>
<AntButton
type="text"
size="small"
icon={copiedStates["prompt-id"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
@ -226,23 +212,15 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
accessToken={accessToken}
version={currentVersion}
/>
<TremorButton
icon={PencilIcon}
variant="primary"
onClick={() => onEdit?.(rawApiResponse)}
className="flex items-center"
>
<Button onClick={() => onEdit?.(rawApiResponse)} className="flex items-center">
<Pencil />
Prompt Studio
</TremorButton>
</Button>
{isAdmin && (
<TremorButton
icon={TrashIcon}
variant="secondary"
onClick={handleDeleteClick}
className="flex items-center"
>
<Button variant="secondary" onClick={handleDeleteClick} className="flex items-center">
<Trash2 />
Delete Prompt
</TremorButton>
</Button>
)}
</div>
</div>
@ -285,81 +263,89 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
{/* Old version banner */}
{isViewingOldVersion && (
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-center justify-between">
<Text className="text-amber-800">
<p className="text-sm text-amber-800">
Viewing v{selectedVersion} not the latest version (v{latestVersion})
</Text>
<TremorButton
variant="light"
size="xs"
</p>
<Button
variant="ghost"
size="sm"
onClick={() => {
const latest = versionHistory.find((v) => v.version === latestVersion);
if (latest) handleVersionClick(latest);
}}
>
Go to latest
</TremorButton>
</Button>
</div>
)}
<TabGroup>
<TabList className="mb-4">
<Tab key="overview">Overview</Tab>
{promptTemplate ? <Tab key="prompt-template">Prompt Template</Tab> : <></>}
<Tab key="raw-json">Raw JSON</Tab>
</TabList>
<Tabs defaultValue="overview">
<TabsList variant="line" className="mb-4 h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="overview" className="flex-none rounded-none px-4 py-2">
Overview
</TabsTrigger>
{promptTemplate && (
<TabsTrigger value="prompt-template" className="flex-none rounded-none px-4 py-2">
Prompt Template
</TabsTrigger>
)}
<TabsTrigger value="raw-json" className="flex-none rounded-none px-4 py-2">
Raw JSON
</TabsTrigger>
</TabsList>
<TabPanels>
<div>
{/* Overview Panel */}
<TabPanel>
<Grid numItems={1} numItemsSm={2} numItemsLg={4} className="gap-4">
<Card>
<Text>Version</Text>
<TabsContent value="overview" keepMounted>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="block p-6">
<p>Version</p>
<div className="mt-2">
<Title>{currentVersion}</Title>
<Badge color="blue" className="mt-1">
<h3 className="text-lg font-medium">{currentVersion}</h3>
<Badge variant="secondary" className="mt-1">
v{currentVersion}
</Badge>
</div>
</Card>
<Card>
<Text>Prompt Type</Text>
<Card className="block p-6">
<p>Prompt Type</p>
<div className="mt-2">
<Title>{promptData.prompt_info?.prompt_type || "-"}</Title>
<h3 className="text-lg font-medium">{promptData.prompt_info?.prompt_type || "-"}</h3>
</div>
</Card>
<Card>
<Text>Created By</Text>
<Card className="block p-6">
<p>Created By</p>
<div className="mt-2">
<Title className="text-sm">{promptData.created_by || "-"}</Title>
<h3 className="text-sm font-medium">{promptData.created_by || "-"}</h3>
</div>
</Card>
<Card>
<Text>Created At</Text>
<Card className="block p-6">
<p>Created At</p>
<div className="mt-2">
<Title className="text-sm">{formatDate(promptData.created_at)}</Title>
<Text className="text-xs">Updated: {formatDate(promptData.updated_at)}</Text>
<h3 className="text-sm font-medium">{formatDate(promptData.created_at)}</h3>
<p className="text-xs">Updated: {formatDate(promptData.updated_at)}</p>
</div>
</Card>
</Grid>
</div>
{/* Version History Table */}
<Card className="mt-6">
<Title className="mb-3">Version History {selectedEnv}</Title>
<Card className="block mt-6 p-6">
<h3 className="text-lg font-medium mb-3">Version History {selectedEnv}</h3>
{loadingVersions ? (
<Text>Loading versions...</Text>
<p>Loading versions...</p>
) : versionHistory.length > 0 ? (
<Table>
<TableHead>
<TableHeader>
<TableRow>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell>Created By</TableHeaderCell>
<TableHeaderCell>Date</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
<TableHead>Version</TableHead>
<TableHead>Created By</TableHead>
<TableHead>Date</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHead>
</TableHeader>
<TableBody>
{versionHistory.map((v) => {
const vNum = v.version || 1;
@ -376,7 +362,7 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
<TableCell>
<span className={isSelected ? "font-bold" : ""}>v{vNum}</span>
{isLatest && (
<Badge color="blue" className="ml-2" size="xs">
<Badge variant="secondary" className="ml-2">
latest
</Badge>
)}
@ -388,10 +374,9 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
<span className="text-sm">{formatDate(v.created_at)}</span>
</TableCell>
<TableCell>
<TremorButton
icon={PencilIcon}
variant="light"
size="xs"
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
// Build a response-like object for the editor
@ -406,8 +391,9 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
onEdit?.(editData);
}}
>
<Pencil />
Edit
</TremorButton>
</Button>
</TableCell>
</TableRow>
);
@ -415,18 +401,18 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
</TableBody>
</Table>
) : (
<Text className="text-gray-400">No versions found in {selectedEnv}</Text>
<p className="text-gray-400">No versions found in {selectedEnv}</p>
)}
</Card>
</TabPanel>
</TabsContent>
{/* Prompt Template Panel */}
{promptTemplate && (
<TabPanel>
<Card>
<TabsContent value="prompt-template" keepMounted>
<Card className="block p-6">
<div className="flex justify-between items-center mb-4">
<Title>Prompt Template</Title>
<Button
<h3 className="text-lg font-medium">Prompt Template</h3>
<AntButton
type="text"
size="small"
icon={copiedStates["prompt-content"] ? <CheckIcon size={16} /> : <CopyIcon size={16} />}
@ -438,19 +424,19 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
}`}
>
{copiedStates["prompt-content"] ? "Copied!" : "Copy Content"}
</Button>
</AntButton>
</div>
<div className="space-y-4">
<div>
<Text className="font-medium">Template ID</Text>
<p className="font-medium">Template ID</p>
<div className="font-mono text-sm bg-gray-50 p-2 rounded-sm">
{promptTemplate.litellm_prompt_id}
</div>
</div>
<div>
<Text className="font-medium">Content</Text>
<p className="font-medium">Content</p>
<div className="mt-2 p-4 bg-gray-50 rounded-md border overflow-auto max-h-96">
<pre className="text-sm text-gray-800 whitespace-pre-wrap">{promptTemplate.content}</pre>
</div>
@ -458,7 +444,7 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
{promptTemplate.metadata && Object.keys(promptTemplate.metadata).length > 0 && (
<div>
<Text className="font-medium">Template Metadata</Text>
<p className="font-medium">Template Metadata</p>
<div className="mt-2 p-3 bg-gray-50 rounded-md border">
<pre className="text-xs text-gray-800 whitespace-pre-wrap overflow-auto max-h-64">
{JSON.stringify(promptTemplate.metadata, null, 2)}
@ -468,15 +454,15 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
)}
</div>
</Card>
</TabPanel>
</TabsContent>
)}
{/* Raw JSON Panel */}
<TabPanel>
<Card>
<TabsContent value="raw-json" keepMounted>
<Card className="block p-6">
<div className="flex justify-between items-center mb-4">
<Title>Raw API Response</Title>
<Button
<h3 className="text-lg font-medium">Raw API Response</h3>
<AntButton
type="text"
size="small"
icon={copiedStates["raw-json"] ? <CheckIcon size={16} /> : <CopyIcon size={16} />}
@ -488,7 +474,7 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
}`}
>
{copiedStates["raw-json"] ? "Copied!" : "Copy JSON"}
</Button>
</AntButton>
</div>
<div className="p-4 bg-gray-50 rounded-md border overflow-auto">
@ -497,9 +483,9 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({ promptId, onClose, accessTo
</pre>
</div>
</Card>
</TabPanel>
</TabPanels>
</TabGroup>
</TabsContent>
</div>
</Tabs>
{/* Delete Confirmation Modal */}
<Modal

View file

@ -375,7 +375,7 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
<Button type="button" variant="outline" onClick={handleCancel} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
<Button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}>
{isSubmitting && <UiLoadingSpinner className="size-4" />}
{isSubmitting ? "Adding..." : "Add Skill"}
</Button>