From c63b67dd6c57ee045a87a5875473c2b688d8772c Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Thu, 19 Feb 2026 19:47:44 -0800 Subject: [PATCH] fix: new version sidebar for flowbuilder --- .../proxy/policy_engine/policy_endpoints.py | 87 +- .../proxy/policy_engine/policy_registry.py | 15 +- .../src/components/policies/index.tsx | 13 + .../policies/pipeline_flow_builder.tsx | 744 +++++++++++++++++- .../components/policies/version_sidebar.tsx | 36 +- ui/litellm-dashboard/tsconfig.json | 2 +- 6 files changed, 790 insertions(+), 107 deletions(-) diff --git a/litellm/proxy/policy_engine/policy_endpoints.py b/litellm/proxy/policy_engine/policy_endpoints.py index 29dc2bdee78..04641d921fa 100644 --- a/litellm/proxy/policy_engine/policy_endpoints.py +++ b/litellm/proxy/policy_engine/policy_endpoints.py @@ -81,6 +81,36 @@ async def list_policies(): raise HTTPException(status_code=500, detail=str(e)) +@router.get( + "/policies/name/{policy_name}/versions", + tags=["Policies"], + dependencies=[Depends(user_api_key_auth)], + response_model=PolicyListDBResponse, +) +async def list_policy_versions(policy_name: str): + """ + List all versions of a policy by policy name. + + Returns all versions ordered by version_number descending (latest first). + Returns 200 with empty list when the policy has no versions (e.g. older + policies created before versioning), so the UI can show "No versions found". + """ + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException(status_code=500, detail="Database not connected") + + try: + versions = await get_policy_registry().get_policy_versions( + policy_name=policy_name, + prisma_client=prisma_client, + ) + return PolicyListDBResponse(policies=versions, total_count=len(versions)) + except Exception as e: + verbose_proxy_logger.exception(f"Error listing policy versions: {e}") + return PolicyListDBResponse(policies=[], total_count=0) + + @router.post( "/policies", tags=["Policies"], @@ -413,63 +443,6 @@ async def create_policy_version( raise HTTPException(status_code=500, detail=str(e)) -@router.get( - "/policies/name/{policy_name}/versions", - tags=["Policies"], - dependencies=[Depends(user_api_key_auth)], - response_model=PolicyListDBResponse, -) -async def list_policy_versions(policy_name: str): - """ - List all versions of a policy by policy name. - - Returns all versions ordered by version_number descending (latest first). - - Example Request: - ```bash - curl -X GET "http://localhost:4000/policies/name/global-baseline/versions" \\ - -H "Authorization: Bearer " - ``` - - Example Response: - ```json - { - "policies": [ - { - "policy_id": "uuid-v2", - "policy_name": "global-baseline", - "version_number": 2, - "version_status": "draft", - "is_latest": true - }, - { - "policy_id": "uuid-v1", - "policy_name": "global-baseline", - "version_number": 1, - "version_status": "production", - "is_latest": false - } - ], - "total_count": 2 - } - ``` - """ - from litellm.proxy.proxy_server import prisma_client - - if prisma_client is None: - raise HTTPException(status_code=500, detail="Database not connected") - - try: - versions = await get_policy_registry().get_policy_versions( - policy_name=policy_name, - prisma_client=prisma_client, - ) - return PolicyListDBResponse(policies=versions, total_count=len(versions)) - except Exception as e: - verbose_proxy_logger.exception(f"Error listing policy versions: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @router.put( "/policies/{policy_id}/status", tags=["Policies"], diff --git a/litellm/proxy/policy_engine/policy_registry.py b/litellm/proxy/policy_engine/policy_registry.py index d5ca0ac75bc..5efed577aaf 100644 --- a/litellm/proxy/policy_engine/policy_registry.py +++ b/litellm/proxy/policy_engine/policy_registry.py @@ -721,12 +721,9 @@ class PolicyRegistry: """ Get all versions of a policy by policy name. - Args: - policy_name: Name of the policy - prisma_client: The Prisma client instance - - Returns: - List of PolicyDBResponse objects ordered by version_number desc + Returns empty list when the policy has no versions (e.g. older policies + created before versioning) or when the query fails (e.g. schema mismatch), + so callers always get a valid response instead of 404/500. """ try: versions = await prisma_client.db.litellm_policytable.find_many( @@ -758,8 +755,10 @@ class PolicyRegistry: for v in versions ] except Exception as e: - verbose_proxy_logger.exception(f"Error getting policy versions: {e}") - raise Exception(f"Error getting policy versions: {str(e)}") + verbose_proxy_logger.warning( + "Error getting policy versions (returning empty list): %s", e + ) + return [] async def update_policy_status( self, diff --git a/ui/litellm-dashboard/src/components/policies/index.tsx b/ui/litellm-dashboard/src/components/policies/index.tsx index f3b350d35e5..4e00da0a46e 100644 --- a/ui/litellm-dashboard/src/components/policies/index.tsx +++ b/ui/litellm-dashboard/src/components/policies/index.tsx @@ -454,6 +454,7 @@ const PoliciesPanel: React.FC = ({ setEditingPolicy(policy); setSelectedPolicyId(null); if (policy.pipeline) { + fetchGuardrails(); setShowFlowBuilder(true); } else { setIsAddPolicyModalVisible(true); @@ -471,6 +472,7 @@ const PoliciesPanel: React.FC = ({ onEditClick={(policy) => { setEditingPolicy(policy); if (policy.pipeline) { + fetchGuardrails(); setShowFlowBuilder(true); } else { setIsAddPolicyModalVisible(true); @@ -657,6 +659,17 @@ const PoliciesPanel: React.FC = ({ updatePolicy={updatePolicyCall} isAdmin={isAdmin} onGuardrailUpdated={fetchGuardrails} + onVersionSelect={async (version) => { + if (!accessToken) return; + try { + const full = await getPolicyInfo(accessToken, version.policy_id); + setEditingPolicy(full); + } catch (err) { + console.error("Failed to load version:", err); + message.error("Failed to load version"); + } + }} + onVersionCreated={() => fetchPolicies()} /> )} diff --git a/ui/litellm-dashboard/src/components/policies/pipeline_flow_builder.tsx b/ui/litellm-dashboard/src/components/policies/pipeline_flow_builder.tsx index d5425d7145b..81610b7e6bf 100644 --- a/ui/litellm-dashboard/src/components/policies/pipeline_flow_builder.tsx +++ b/ui/litellm-dashboard/src/components/policies/pipeline_flow_builder.tsx @@ -1,13 +1,32 @@ -import React, { useState } from "react"; -import { Select, Typography, message, Modal } from "antd"; +import React, { useState, useEffect, useMemo } from "react"; +import { Select, Typography, message, Modal, Spin, Divider, Form } from "antd"; import { Button, TextInput } from "@tremor/react"; -import { ArrowLeftIcon, PlusIcon } from "@heroicons/react/outline"; +import { + ArrowLeftIcon, + PlusIcon, + ChevronUpIcon, + ChevronDownIcon, + ClockIcon, + CheckCircleIcon, + SwitchHorizontalIcon, +} from "@heroicons/react/outline"; import { DotsVerticalIcon, PencilIcon, BeakerIcon } from "@heroicons/react/solid"; -import { GuardrailPipeline, PipelineStep, PipelineTestResult, PolicyCreateRequest, PolicyUpdateRequest, Policy } from "./types"; +import { + GuardrailPipeline, + PipelineStep, + PipelineTestResult, + PolicyCreateRequest, + PolicyUpdateRequest, + Policy, + PolicyVersionListResponse, +} from "./types"; import { Guardrail } from "../guardrails/types"; -import { testPipelineCall } from "../networking"; +import { testPipelineCall, listPolicyVersions, createPolicyVersion, updatePolicyVersionStatus } from "../networking"; import NotificationsManager from "../molecules/notifications_manager"; import GuardrailInfoView from "../guardrails/guardrail_info"; +import VersionStatusBadge from "./version_status_badge"; +import VersionComparison from "./version_comparison"; +import { getFrameworks } from "@/data/compliancePrompts"; const { Text } = Typography; @@ -38,7 +57,13 @@ function createDefaultStep(): PipelineStep { /** Build initial pipeline from a policy (uses pipeline if present, else guardrails_add as steps). */ function getInitialPipelineFromPolicy(policy: Policy | null | undefined): GuardrailPipeline { if (!policy) return { mode: "pre_call", steps: [createDefaultStep()] }; - if (policy.pipeline?.steps?.length) return policy.pipeline; + if ( + policy.pipeline?.steps && + Array.isArray(policy.pipeline.steps) && + policy.pipeline.steps.length > 0 + ) { + return policy.pipeline; + } const add = policy.guardrails_add || []; if (add.length === 0) return { mode: "pre_call", steps: [createDefaultStep()] }; return { @@ -414,18 +439,25 @@ const PipelineFlowBuilder: React.FC = ({ const [editingGuardrailName, setEditingGuardrailName] = useState(null); const [testingGuardrailName, setTestingGuardrailName] = useState(null); + const steps = Array.isArray(pipeline?.steps) ? pipeline.steps : [createDefaultStep()]; + const guardrails = availableGuardrails ?? []; + const safePipeline: GuardrailPipeline = { + mode: pipeline?.mode ?? "pre_call", + steps, + }; + const handleInsertStep = (atIndex: number) => { - onChange({ ...pipeline, steps: insertStep(pipeline.steps, atIndex) }); + onChange({ ...safePipeline, steps: insertStep(steps, atIndex) }); }; const handleRemoveStep = (index: number) => { - onChange({ ...pipeline, steps: removeStep(pipeline.steps, index) }); + onChange({ ...safePipeline, steps: removeStep(steps, index) }); }; const handleUpdateStep = (index: number, updated: Partial) => { onChange({ - ...pipeline, - steps: updateStepAtIndex(pipeline.steps, index, updated), + ...safePipeline, + steps: updateStepAtIndex(steps, index, updated), }); }; @@ -451,7 +483,7 @@ const PipelineFlowBuilder: React.FC = ({ // Find the guardrail ID for the selected guardrail name const getGuardrailId = (guardrailName: string | null): string | null => { if (!guardrailName) return null; - const guardrail = availableGuardrails.find( + const guardrail = guardrails.find( (g) => g.guardrail_name === guardrailName || g.guardrail_id === guardrailName ); return guardrail?.guardrail_id || null; @@ -497,16 +529,16 @@ const PipelineFlowBuilder: React.FC = ({ {/* Steps */} - {pipeline.steps.map((step, index) => ( + {steps.map((step, index) => ( handleInsertStep(index)} /> handleUpdateStep(index, updated)} onDelete={() => handleRemoveStep(index)} - availableGuardrails={availableGuardrails} + availableGuardrails={guardrails} onEditGuardrail={handleEditGuardrail} onTestGuardrail={handleTestGuardrail} /> @@ -514,7 +546,7 @@ const PipelineFlowBuilder: React.FC = ({ ))} {/* Bottom connector */} - handleInsertStep(pipeline.steps.length)} /> + handleInsertStep(steps.length)} /> {/* End card */}
= ({ pipeline }) => ( +export const PipelineInfoDisplay: React.FC = ({ pipeline }) => { + const steps = Array.isArray(pipeline?.steps) ? pipeline.steps : []; + return (
{/* Trigger */}
= ({ pipeli
{/* Steps */} - {pipeline.steps.map((step, index) => ( + {steps.map((step, index) => ( {/* Connector */}
@@ -712,7 +746,8 @@ export const PipelineInfoDisplay: React.FC = ({ pipeli ))}
-); + ); +}; // ───────────────────────────────────────────────────────────────────────────── // Pipeline Test Panel (right drawer) @@ -736,6 +771,16 @@ const TERMINAL_STYLES: Record = { modify_response: { bg: "#eff6ff", color: "#2563eb" }, }; +type ComplianceTestResult = { + promptId: string; + prompt: string; + expectedResult: "fail" | "pass"; + actualResult: "blocked" | "allowed"; + isMatch: boolean; + status: "complete" | "pending" | "error"; + error?: string; +}; + const PipelineTestPanel: React.FC = ({ pipeline, accessToken, @@ -746,10 +791,35 @@ const PipelineTestPanel: React.FC = ({ const [result, setResult] = useState(null); const [error, setError] = useState(null); + const steps = Array.isArray(pipeline?.steps) ? pipeline.steps : []; + + const [testTab, setTestTab] = useState<"quick" | "compliance">("quick"); + const complianceFrameworks = useMemo(() => getFrameworks(), []); + const allCompliancePrompts = useMemo( + () => complianceFrameworks.flatMap((fw) => fw.categories.flatMap((c) => c.prompts)), + [complianceFrameworks] + ); + const [selectedPromptIds, setSelectedPromptIds] = useState>(new Set()); + const [complianceResults, setComplianceResults] = useState([]); + const [isRunningCompliance, setIsRunningCompliance] = useState(false); + + const togglePromptSelection = (id: string) => { + setSelectedPromptIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const selectAllPrompts = () => { + setSelectedPromptIds(new Set(allCompliancePrompts.map((p) => p.id))); + }; + const handleRunTest = async () => { if (!accessToken) return; - const emptySteps = pipeline.steps.filter((s) => !s.guardrail); + const emptySteps = steps.filter((s) => !s.guardrail); if (emptySteps.length > 0) { setError("All steps must have a guardrail selected"); return; @@ -773,10 +843,65 @@ const PipelineTestPanel: React.FC = ({ } }; + const handleRunComplianceTests = async () => { + if (!accessToken || selectedPromptIds.size === 0) return; + + const emptySteps = steps.filter((s) => !s.guardrail); + if (emptySteps.length > 0) { + setError("All steps must have a guardrail selected"); + return; + } + + const selected = allCompliancePrompts.filter((p) => selectedPromptIds.has(p.id)); + setComplianceResults( + selected.map((p) => ({ + promptId: p.id, + prompt: p.prompt, + expectedResult: p.expectedResult, + actualResult: "allowed" as const, + isMatch: false, + status: "pending" as const, + })) + ); + setIsRunningCompliance(true); + setError(null); + + for (let i = 0; i < selected.length; i++) { + const p = selected[i]; + try { + const data = await testPipelineCall(accessToken, pipeline, [ + { role: "user", content: p.prompt }, + ]); + const actualResult: "blocked" | "allowed" = + data.terminal_action === "block" ? "blocked" : "allowed"; + const isMatch = + (p.expectedResult === "fail" && actualResult === "blocked") || + (p.expectedResult === "pass" && actualResult === "allowed"); + setComplianceResults((prev) => + prev.map((r) => + r.promptId === p.id + ? { ...r, actualResult, isMatch, status: "complete" as const } + : r + ) + ); + } catch (e) { + const errMsg = e instanceof Error ? e.message : String(e); + setComplianceResults((prev) => + prev.map((r) => + r.promptId === p.id + ? { ...r, status: "error" as const, error: errMsg, actualResult: "blocked" as const } + : r + ) + ); + } + } + setIsRunningCompliance(false); + }; + return (
= ({
- {/* Input section */} + {/* Tabs: Quick test | Compliance */} +
+ + +
+ + {testTab === "quick" && (
+ )} + + {testTab === "compliance" && ( +
+

+ Same datasets as Compliance playground. Select prompts and run. +

+ +
+ {complianceFrameworks.map((fw) => ( +
+
+ {fw.name} +
+ {fw.categories.map((cat) => ( +
+
{cat.name}
+ {cat.prompts.map((p) => ( + + ))} +
+ ))} +
+ ))} +
+ +
+ )} {/* Results section */} -
+
{error && (
= ({
)} - {result && ( + {testTab === "compliance" && complianceResults.length > 0 && ( +
+
+ Results +
+
+ {complianceResults.filter((r) => r.status === "complete").length} / {complianceResults.length} complete + {" · "} + {complianceResults.filter((r) => r.isMatch).length} match expected +
+
+ {complianceResults.map((r) => ( +
+
+ Expected: {r.expectedResult} → Actual: {r.actualResult} + {r.status === "complete" && ( + + {r.isMatch ? "✓ Match" : "✗ Mismatch"} + + )} + {r.status === "error" && r.error && ( + {r.error} + )} +
+
+ {r.prompt.slice(0, 120)} + {r.prompt.length > 120 ? "…" : ""} +
+
+ ))} +
+
+ )} + + {testTab === "quick" && result && (
{/* Step results */} {result.step_results.map((step, i) => { @@ -950,16 +1224,391 @@ const PipelineTestPanel: React.FC = ({
)} - {!result && !error && ( + {testTab === "quick" && !result && !error && (
Enter a test message and click "Run Test" to execute the pipeline
)} + {testTab === "compliance" && complianceResults.length === 0 && !error && ( +
+ Select prompts above and click "Run selected" to test with compliance datasets +
+ )}
); }; +// ───────────────────────────────────────────────────────────────────────────── +// Version sidebar for Flow Builder (self-contained, used only in this page) +// ───────────────────────────────────────────────────────────────────────────── + +interface FlowBuilderVersionSidebarProps { + editingPolicy: Policy; + accessToken: string | null; + onVersionSelect: (version: Policy) => void; + onVersionCreated: () => void; +} + +const FlowBuilderVersionSidebar: React.FC = ({ + editingPolicy, + accessToken, + onVersionSelect, + onVersionCreated, +}) => { + const policyName = editingPolicy.policy_name; + const currentPolicyId = editingPolicy.policy_id; + + const [versions, setVersions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(null); + const [compareModalOpen, setCompareModalOpen] = useState(false); + const [comparePolicyId1, setComparePolicyId1] = useState(""); + const [comparePolicyId2, setComparePolicyId2] = useState(""); + + const loadVersions = async () => { + if (!accessToken || !policyName) return; + setIsLoading(true); + try { + const response: PolicyVersionListResponse = await listPolicyVersions(accessToken, policyName); + setVersions(response.policies || []); + } catch (error) { + console.error("Failed to load versions:", error); + const errMsg = error instanceof Error ? error.message : String(error); + if (!errMsg.includes("column") && !errMsg.includes("version_number")) { + NotificationsManager.fromBackend("Failed to load policy versions: " + errMsg); + } + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadVersions(); + }, [policyName, accessToken]); + + const handleCreateNewVersion = async () => { + if (!accessToken) return; + Modal.confirm({ + title: "Create New Version", + content: "Create a new draft version from the current policy?", + okText: "Create", + cancelText: "Cancel", + onOk: async () => { + setActionLoading("create"); + try { + await createPolicyVersion(accessToken, currentPolicyId); + NotificationsManager.success("New version created successfully"); + await loadVersions(); + onVersionCreated(); + } catch (error) { + console.error("Failed to create version:", error); + const errMsg = error instanceof Error ? error.message : String(error); + if ( + errMsg.includes("column") || + errMsg.includes("version_number") || + errMsg.includes("schema") + ) { + NotificationsManager.error( + "Database migration required. Run: poetry run prisma migrate dev --name add_policy_versioning" + ); + } else { + NotificationsManager.fromBackend("Failed to create version: " + errMsg); + } + } finally { + setActionLoading(null); + } + }, + }); + }; + + const handlePromoteToPublished = async (policyId: string, versionNumber: number) => { + if (!accessToken) return; + Modal.confirm({ + title: "Promote to Published", + content: `Promote version ${versionNumber} to Published status?`, + okText: "Promote", + cancelText: "Cancel", + onOk: async () => { + setActionLoading(policyId); + try { + await updatePolicyVersionStatus(accessToken, policyId, "published"); + NotificationsManager.success("Version promoted to Published"); + await loadVersions(); + } catch (error) { + console.error("Failed to promote version:", error); + NotificationsManager.fromBackend( + "Failed to promote version: " + (error instanceof Error ? error.message : String(error)) + ); + } finally { + setActionLoading(null); + } + }, + }); + }; + + const handlePromoteToProduction = async (policyId: string, versionNumber: number) => { + if (!accessToken) return; + Modal.confirm({ + title: "Promote to Production", + content: `Promote version ${versionNumber} to Production? Any existing production version will be demoted to Published.`, + okText: "Promote", + cancelText: "Cancel", + okType: "primary", + onOk: async () => { + setActionLoading(policyId); + try { + await updatePolicyVersionStatus(accessToken, policyId, "production"); + NotificationsManager.success("Version promoted to Production"); + await loadVersions(); + } catch (error) { + console.error("Failed to promote version:", error); + NotificationsManager.fromBackend( + "Failed to promote version: " + (error instanceof Error ? error.message : String(error)) + ); + } finally { + setActionLoading(null); + } + }, + }); + }; + + const handleDemote = async (policyId: string, versionNumber: number) => { + if (!accessToken) return; + Modal.confirm({ + title: "Demote Version", + content: `Demote version ${versionNumber} from Production to Published?`, + okText: "Demote", + cancelText: "Cancel", + onOk: async () => { + setActionLoading(policyId); + try { + await updatePolicyVersionStatus(accessToken, policyId, "published"); + NotificationsManager.success("Version demoted to Published"); + await loadVersions(); + } catch (error) { + console.error("Failed to demote version:", error); + NotificationsManager.fromBackend( + "Failed to demote version: " + (error instanceof Error ? error.message : String(error)) + ); + } finally { + setActionLoading(null); + } + }, + }); + }; + + const formatDate = (dateString?: string | null) => { + if (!dateString) return "N/A"; + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + }; + + const getActionButtons = (version: Policy) => { + const isProcessing = actionLoading === version.policy_id; + const status = version.version_status; + + if (status === "draft") { + return ( + + ); + } + if (status === "published") { + return ( + + ); + } + if (status === "production") { + return ( + + ); + } + return null; + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + Versions + + +
+ +
+ {versions.length === 0 ? ( + + No versions found + + ) : ( + versions.map((version) => { + const isActive = version.policy_id === currentPolicyId; + const versionNumber = version.version_number ?? 1; + const status = version.version_status ?? "draft"; + return ( +
onVersionSelect(version)} + > +
+
+ + v{versionNumber} + + {isActive && } +
+ +
+
+ + + {formatDate(version.created_at)} + +
+ {version.description && ( + + {version.description} + + )} +
{getActionButtons(version)}
+
+ ); + }) + )} +
+ + {versions.length >= 2 && ( +
+ +
+ )} + { + setCompareModalOpen(false); + setComparePolicyId1(""); + setComparePolicyId2(""); + }} + footer={null} + width={720} + destroyOnClose + > +
+
+ + ({ + label: `v${v.version_number ?? 1} (${v.version_status ?? "draft"})`, + value: v.policy_id, + }))} + /> + +
+
+ {comparePolicyId1 && comparePolicyId2 && comparePolicyId1 !== comparePolicyId2 && ( +
+ +
+ )} + {comparePolicyId1 && comparePolicyId2 && comparePolicyId1 === comparePolicyId2 && ( + Select two different versions to compare. + )} +
+
+ ); +}; + // ───────────────────────────────────────────────────────────────────────────── // Full-screen Flow Builder Page // ───────────────────────────────────────────────────────────────────────────── @@ -974,6 +1623,8 @@ interface FlowBuilderPageProps { updatePolicy: (accessToken: string, policyId: string, policyData: any) => Promise; isAdmin: boolean; onGuardrailUpdated?: () => void; + onVersionSelect?: (version: Policy) => void; + onVersionCreated?: () => void; } export const FlowBuilderPage: React.FC = ({ @@ -986,6 +1637,8 @@ export const FlowBuilderPage: React.FC = ({ updatePolicy, isAdmin, onGuardrailUpdated, + onVersionSelect, + onVersionCreated, }) => { const isEditing = !!editingPolicy?.policy_id; @@ -997,6 +1650,15 @@ export const FlowBuilderPage: React.FC = ({ getInitialPipelineFromPolicy(editingPolicy) ); + // Sync local state when editingPolicy changes (e.g. user selected another version in sidebar) + useEffect(() => { + if (editingPolicy) { + setPolicyName(editingPolicy.policy_name || ""); + setDescription(editingPolicy.description || ""); + setPipeline(getInitialPipelineFromPolicy(editingPolicy)); + } + }, [editingPolicy?.policy_id]); + const handleSave = async () => { if (!policyName.trim()) { message.error("Please enter a policy name"); @@ -1007,7 +1669,8 @@ export const FlowBuilderPage: React.FC = ({ return; } - const emptySteps = pipeline.steps.filter((s) => !s.guardrail); + const steps = Array.isArray(pipeline?.steps) ? pipeline.steps : []; + const emptySteps = steps.filter((s) => !s.guardrail); if (emptySteps.length > 0) { message.error("Please select a guardrail for all steps"); return; @@ -1015,7 +1678,7 @@ export const FlowBuilderPage: React.FC = ({ setIsSubmitting(true); try { - const guardrailsFromPipeline = pipeline.steps + const guardrailsFromPipeline = steps .map((s) => s.guardrail) .filter(Boolean); @@ -1024,7 +1687,7 @@ export const FlowBuilderPage: React.FC = ({ description: description || undefined, guardrails_add: guardrailsFromPipeline, guardrails_remove: [], - pipeline: pipeline, + pipeline: { mode: pipeline?.mode ?? "pre_call", steps }, }; if (isEditing && editingPolicy) { @@ -1135,29 +1798,50 @@ export const FlowBuilderPage: React.FC = ({ backgroundColor: "#fff", borderBottom: "1px solid #e5e7eb", flexShrink: 0, + minWidth: 0, }} > setDescription(e.target.value)} - style={{ maxWidth: 500 }} + style={{ maxWidth: "100%", width: 560 }} />
- {/* Flow builder canvas + test panel */} + {/* Flow builder canvas + test panel (with optional version sidebar when editing) */}
+ {isEditing && editingPolicy && onVersionSelect && onVersionCreated && ( +
+ +
+ )}
-
+
= ({ setVersions(response.policies || []); } catch (error) { console.error("Failed to load versions:", error); - NotificationsManager.fromBackend("Failed to load policy versions"); + // Check if it's a database schema issue + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("column") || errorMessage.includes("version_number")) { + console.warn("Database migration needed - version columns don't exist yet"); + // Don't show error to user, just log it + } else { + // NotificationsManager.fromBackend("Failed to load policy versions: " + errorMessage); + return + } } finally { setIsLoading(false); } @@ -83,10 +91,16 @@ const VersionSidebar: React.FC = ({ onVersionCreated(); } catch (error) { console.error("Failed to create version:", error); - NotificationsManager.fromBackend( - "Failed to create version: " + - (error instanceof Error ? error.message : String(error)) - ); + const errorMessage = error instanceof Error ? error.message : String(error); + + // Check if it's a database schema issue + if (errorMessage.includes("column") || errorMessage.includes("version_number") || errorMessage.includes("schema")) { + NotificationsManager.error( + "Database migration required. Run: poetry run prisma migrate dev --name add_policy_versioning" + ); + } else { + NotificationsManager.fromBackend("Failed to create version: " + errorMessage); + } } finally { setActionLoading(null); } @@ -248,12 +262,12 @@ const VersionSidebar: React.FC = ({ } return ( -
+
{/* Header with Create New Version button */} -
- +
+ Versions - +
- + {/* Version List */}
@@ -355,7 +369,7 @@ const VersionSidebar: React.FC = ({ {/* Silent Mirroring - Coming Soon */}