feat(ui/): new policy editor view

This commit is contained in:
Krrish Dholakia 2026-02-19 19:20:27 -08:00
parent 9ea62fab04
commit 38406d7efe
17 changed files with 2264 additions and 48 deletions

4
.gitignore vendored
View file

@ -100,4 +100,6 @@ STABILIZATION_TODO.md
**/test-results
**/playwright-report
**/*.storageState.json
**/coverage
**/coverage
ui/litellm-dashboard/out/__next.*

View file

@ -352,6 +352,257 @@ async def get_resolved_guardrails(policy_id: str):
raise HTTPException(status_code=500, detail=str(e))
# ─────────────────────────────────────────────────────────────────────────────
# Policy Version Management Endpoints
# ─────────────────────────────────────────────────────────────────────────────
@router.post(
"/policies/{policy_id}/versions",
tags=["Policies"],
dependencies=[Depends(user_api_key_auth)],
response_model=PolicyDBResponse,
)
async def create_policy_version(
policy_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Create a new version from an existing policy.
The new version will be created as a draft with version_number incremented.
All policy configuration (guardrails, pipeline, condition) will be copied from the source version.
Example Request:
```bash
curl -X POST "http://localhost:4000/policies/123e4567-e89b-12d3-a456-426614174000/versions" \\
-H "Authorization: Bearer <your_api_key>"
```
Example Response:
```json
{
"policy_id": "new-uuid-here",
"policy_name": "global-baseline",
"version_number": 2,
"version_status": "draft",
"parent_version_id": "123e4567-e89b-12d3-a456-426614174000",
"description": "Base guardrails for all requests",
"guardrails_add": ["pii_masking"],
"guardrails_remove": []
}
```
"""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(status_code=500, detail="Database not connected")
try:
created_by = user_api_key_dict.user_id
result = await get_policy_registry().create_policy_version(
policy_id=policy_id,
prisma_client=prisma_client,
created_by=created_by,
)
return result
except HTTPException:
raise
except Exception as e:
verbose_proxy_logger.exception(f"Error creating policy version: {e}")
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 <your_api_key>"
```
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"],
dependencies=[Depends(user_api_key_auth)],
response_model=PolicyDBResponse,
)
async def update_policy_version_status(
policy_id: str,
status: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Change the status of a policy version.
Valid status transitions:
- draft → published
- published → production
- production → published (demote)
When promoting to production, the previous production version (if any) will be demoted to published.
Example Request:
```bash
curl -X PUT "http://localhost:4000/policies/123e4567-e89b-12d3-a456-426614174000/status?status=published" \\
-H "Authorization: Bearer <your_api_key>"
```
Example Response:
```json
{
"policy_id": "123e4567-e89b-12d3-a456-426614174000",
"policy_name": "global-baseline",
"version_number": 2,
"version_status": "published",
"published_at": "2024-01-15T10:30:00Z"
}
```
"""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(status_code=500, detail="Database not connected")
# Validate status
valid_statuses = {"draft", "published", "production"}
if status not in valid_statuses:
raise HTTPException(
status_code=400,
detail=f"Invalid status '{status}'. Must be one of: {valid_statuses}",
)
try:
updated_by = user_api_key_dict.user_id
result = await get_policy_registry().update_policy_status(
policy_id=policy_id,
new_status=status,
prisma_client=prisma_client,
updated_by=updated_by,
)
return result
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
verbose_proxy_logger.exception(f"Error updating policy status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/policies/{policy_id}/compare/{compare_with_id}",
tags=["Policies"],
dependencies=[Depends(user_api_key_auth)],
)
async def compare_policy_versions(policy_id: str, compare_with_id: str):
"""
Compare two policy versions and return their differences.
Returns a structured diff showing what changed between the two versions.
Example Request:
```bash
curl -X GET "http://localhost:4000/policies/uuid-v2/compare/uuid-v1" \\
-H "Authorization: Bearer <your_api_key>"
```
Example Response:
```json
{
"policy_1": {
"policy_id": "uuid-v2",
"version_number": 2,
"version_status": "draft"
},
"policy_2": {
"policy_id": "uuid-v1",
"version_number": 1,
"version_status": "production"
},
"differences": {
"guardrails_add": {
"added": ["toxicity_filter"],
"removed": [],
"unchanged": ["pii_masking"]
},
"description": {
"changed": true,
"old": "Base guardrails",
"new": "Enhanced base guardrails"
}
}
}
```
"""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(status_code=500, detail="Database not connected")
try:
comparison = await get_policy_registry().compare_policy_versions(
policy_id_1=policy_id,
policy_id_2=compare_with_id,
prisma_client=prisma_client,
)
return comparison
except HTTPException:
raise
except Exception as e:
verbose_proxy_logger.exception(f"Error comparing policy versions: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ─────────────────────────────────────────────────────────────────────────────
# Pipeline Test Endpoint
# ─────────────────────────────────────────────────────────────────────────────
@ -373,6 +624,8 @@ async def test_pipeline(
step-by-step results showing which guardrails passed/failed, actions taken,
and timing information.
Optionally filter to test only specific guardrails using the guardrail_filter parameter.
Example Request:
```bash
curl -X POST "http://localhost:4000/policies/test-pipeline" \\
@ -388,12 +641,38 @@ async def test_pipeline(
"test_messages": [{"role": "user", "content": "My SSN is 123-45-6789"}]
}'
```
Example with guardrail filtering:
```bash
curl -X POST "http://localhost:4000/policies/test-pipeline" \\
-H "Authorization: Bearer <your_api_key>" \\
-H "Content-Type: application/json" \\
-d '{
"pipeline": {...},
"test_messages": [...],
"guardrail_filter": ["pii-guard"]
}'
```
"""
try:
validated_pipeline = GuardrailPipeline(**request.pipeline)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid pipeline: {e}")
# Filter steps if guardrail_filter is provided
steps_to_execute = validated_pipeline.steps
if request.guardrail_filter:
steps_to_execute = [
step
for step in validated_pipeline.steps
if step.guardrail in request.guardrail_filter
]
if not steps_to_execute:
raise HTTPException(
status_code=400,
detail=f"No steps match the guardrail filter: {request.guardrail_filter}",
)
data = {
"messages": request.test_messages,
"model": "test",
@ -402,7 +681,7 @@ async def test_pipeline(
try:
result = await PipelineExecutor.execute_steps(
steps=validated_pipeline.steps,
steps=steps_to_execute,
mode=validated_pipeline.mode,
data=data,
user_api_key_dict=user_api_key_dict,

View file

@ -285,6 +285,12 @@ class PolicyRegistry:
guardrails_remove=created_policy.guardrails_remove or [],
condition=created_policy.condition,
pipeline=created_policy.pipeline,
version_number=getattr(created_policy, "version_number", 1),
version_status=getattr(created_policy, "version_status", "production"),
parent_version_id=getattr(created_policy, "parent_version_id", None),
is_latest=getattr(created_policy, "is_latest", True),
published_at=getattr(created_policy, "published_at", None),
production_at=getattr(created_policy, "production_at", None),
created_at=created_policy.created_at,
updated_at=created_policy.updated_at,
created_by=created_policy.created_by,
@ -366,6 +372,12 @@ class PolicyRegistry:
guardrails_remove=updated_policy.guardrails_remove or [],
condition=updated_policy.condition,
pipeline=updated_policy.pipeline,
version_number=getattr(updated_policy, "version_number", 1),
version_status=getattr(updated_policy, "version_status", "production"),
parent_version_id=getattr(updated_policy, "parent_version_id", None),
is_latest=getattr(updated_policy, "is_latest", True),
published_at=getattr(updated_policy, "published_at", None),
production_at=getattr(updated_policy, "production_at", None),
created_at=updated_policy.created_at,
updated_at=updated_policy.updated_at,
created_by=updated_policy.created_by,
@ -444,6 +456,12 @@ class PolicyRegistry:
guardrails_remove=policy.guardrails_remove or [],
condition=policy.condition,
pipeline=policy.pipeline,
version_number=getattr(policy, "version_number", 1),
version_status=getattr(policy, "version_status", "production"),
parent_version_id=getattr(policy, "parent_version_id", None),
is_latest=getattr(policy, "is_latest", True),
published_at=getattr(policy, "published_at", None),
production_at=getattr(policy, "production_at", None),
created_at=policy.created_at,
updated_at=policy.updated_at,
created_by=policy.created_by,
@ -481,6 +499,12 @@ class PolicyRegistry:
guardrails_remove=p.guardrails_remove or [],
condition=p.condition,
pipeline=p.pipeline,
version_number=getattr(p, "version_number", 1),
version_status=getattr(p, "version_status", "production"),
parent_version_id=getattr(p, "parent_version_id", None),
is_latest=getattr(p, "is_latest", True),
published_at=getattr(p, "published_at", None),
production_at=getattr(p, "production_at", None),
created_at=p.created_at,
updated_at=p.updated_at,
created_by=p.created_by,
@ -582,6 +606,402 @@ class PolicyRegistry:
verbose_proxy_logger.exception(f"Error resolving guardrails from DB: {e}")
raise Exception(f"Error resolving guardrails from DB: {str(e)}")
# ─────────────────────────────────────────────────────────────────────────
# Policy Versioning Methods
# ─────────────────────────────────────────────────────────────────────────
async def create_policy_version(
self,
policy_id: str,
prisma_client: "PrismaClient",
created_by: Optional[str] = None,
) -> PolicyDBResponse:
"""
Create a new version from an existing policy.
The new version will:
- Be created as a draft
- Have version_number incremented from the latest version
- Copy all configuration from the source policy
- Set parent_version_id to the source policy_id
Args:
policy_id: ID of the policy to create a version from
prisma_client: The Prisma client instance
created_by: User who created the version
Returns:
PolicyDBResponse with the new version
"""
try:
# Get the source policy
source_policy = await prisma_client.db.litellm_policytable.find_unique(
where={"policy_id": policy_id}
)
if source_policy is None:
from fastapi import HTTPException
raise HTTPException(
status_code=404, detail=f"Policy with ID {policy_id} not found"
)
# Get the latest version number for this policy name
existing_versions = await prisma_client.db.litellm_policytable.find_many(
where={"policy_name": source_policy.policy_name},
order_by={"version_number": "desc"},
)
new_version_number = (
existing_versions[0].version_number + 1 if existing_versions else 1
)
# Mark previous versions as not latest
for version in existing_versions:
if version.is_latest:
await prisma_client.db.litellm_policytable.update(
where={"policy_id": version.policy_id},
data={"is_latest": False},
)
# Create new version with copied configuration
new_version_data = {
"policy_name": source_policy.policy_name,
"inherit": source_policy.inherit,
"description": source_policy.description,
"guardrails_add": source_policy.guardrails_add or [],
"guardrails_remove": source_policy.guardrails_remove or [],
"condition": source_policy.condition,
"pipeline": source_policy.pipeline,
"version_number": new_version_number,
"version_status": "draft",
"parent_version_id": policy_id,
"is_latest": True,
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
}
if created_by is not None:
new_version_data["created_by"] = created_by
new_version_data["updated_by"] = created_by
created_version = await prisma_client.db.litellm_policytable.create(
data=new_version_data
)
return PolicyDBResponse(
policy_id=created_version.policy_id,
policy_name=created_version.policy_name,
inherit=created_version.inherit,
description=created_version.description,
guardrails_add=created_version.guardrails_add or [],
guardrails_remove=created_version.guardrails_remove or [],
condition=created_version.condition,
pipeline=created_version.pipeline,
version_number=created_version.version_number,
version_status=created_version.version_status,
parent_version_id=created_version.parent_version_id,
is_latest=created_version.is_latest,
published_at=created_version.published_at,
production_at=created_version.production_at,
created_at=created_version.created_at,
updated_at=created_version.updated_at,
created_by=created_version.created_by,
updated_by=created_version.updated_by,
)
except Exception as e:
verbose_proxy_logger.exception(f"Error creating policy version: {e}")
raise
async def get_policy_versions(
self,
policy_name: str,
prisma_client: "PrismaClient",
) -> List[PolicyDBResponse]:
"""
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
"""
try:
versions = await prisma_client.db.litellm_policytable.find_many(
where={"policy_name": policy_name},
order_by={"version_number": "desc"},
)
return [
PolicyDBResponse(
policy_id=v.policy_id,
policy_name=v.policy_name,
inherit=v.inherit,
description=v.description,
guardrails_add=v.guardrails_add or [],
guardrails_remove=v.guardrails_remove or [],
condition=v.condition,
pipeline=v.pipeline,
version_number=v.version_number,
version_status=v.version_status,
parent_version_id=v.parent_version_id,
is_latest=v.is_latest,
published_at=v.published_at,
production_at=v.production_at,
created_at=v.created_at,
updated_at=v.updated_at,
created_by=v.created_by,
updated_by=v.updated_by,
)
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)}")
async def update_policy_status(
self,
policy_id: str,
new_status: str,
prisma_client: "PrismaClient",
updated_by: Optional[str] = None,
) -> PolicyDBResponse:
"""
Update the status of a policy version.
Valid transitions:
- draft → published
- published → production
- production → published (demote)
When promoting to production, previous production versions are demoted to published.
Args:
policy_id: ID of the policy to update
new_status: New status (draft, published, or production)
prisma_client: The Prisma client instance
updated_by: User who updated the status
Returns:
PolicyDBResponse with the updated policy
Raises:
ValueError: If the status transition is invalid
"""
try:
# Get the policy
policy = await prisma_client.db.litellm_policytable.find_unique(
where={"policy_id": policy_id}
)
if policy is None:
from fastapi import HTTPException
raise HTTPException(
status_code=404, detail=f"Policy with ID {policy_id} not found"
)
current_status = policy.version_status
# Validate status transition
valid_transitions = {
"draft": {"published"},
"published": {"production", "draft"},
"production": {"published"},
}
if (
new_status != current_status
and new_status not in valid_transitions.get(current_status, set())
):
raise ValueError(
f"Invalid status transition from '{current_status}' to '{new_status}'. "
f"Valid transitions: {valid_transitions.get(current_status, set())}"
)
# Build update data
update_data: Dict[str, Any] = {
"version_status": new_status,
"updated_at": datetime.now(timezone.utc),
}
if updated_by is not None:
update_data["updated_by"] = updated_by
# Set timestamp fields based on new status
if new_status == "published" and current_status == "draft":
update_data["published_at"] = datetime.now(timezone.utc)
elif new_status == "production":
update_data["production_at"] = datetime.now(timezone.utc)
# Demote other production versions of this policy to published
await prisma_client.db.litellm_policytable.update_many(
where={
"policy_name": policy.policy_name,
"version_status": "production",
},
data={"version_status": "published"},
)
# Update the policy
updated_policy = await prisma_client.db.litellm_policytable.update(
where={"policy_id": policy_id},
data=update_data,
)
# If promoted to production, sync to in-memory registry
if new_status == "production":
policy_obj = self._parse_policy(
updated_policy.policy_name,
{
"inherit": updated_policy.inherit,
"description": updated_policy.description,
"guardrails": {
"add": updated_policy.guardrails_add,
"remove": updated_policy.guardrails_remove,
},
"condition": updated_policy.condition,
"pipeline": updated_policy.pipeline,
},
)
self.add_policy(updated_policy.policy_name, policy_obj)
return PolicyDBResponse(
policy_id=updated_policy.policy_id,
policy_name=updated_policy.policy_name,
inherit=updated_policy.inherit,
description=updated_policy.description,
guardrails_add=updated_policy.guardrails_add or [],
guardrails_remove=updated_policy.guardrails_remove or [],
condition=updated_policy.condition,
pipeline=updated_policy.pipeline,
version_number=updated_policy.version_number,
version_status=updated_policy.version_status,
parent_version_id=updated_policy.parent_version_id,
is_latest=updated_policy.is_latest,
published_at=updated_policy.published_at,
production_at=updated_policy.production_at,
created_at=updated_policy.created_at,
updated_at=updated_policy.updated_at,
created_by=updated_policy.created_by,
updated_by=updated_policy.updated_by,
)
except Exception as e:
verbose_proxy_logger.exception(f"Error updating policy status: {e}")
raise
async def compare_policy_versions(
self,
policy_id_1: str,
policy_id_2: str,
prisma_client: "PrismaClient",
) -> Dict[str, Any]:
"""
Compare two policy versions and return their differences.
Args:
policy_id_1: ID of the first policy
policy_id_2: ID of the second policy
prisma_client: The Prisma client instance
Returns:
Dict with comparison results showing differences
"""
try:
# Get both policies
policy1 = await self.get_policy_by_id_from_db(policy_id_1, prisma_client)
policy2 = await self.get_policy_by_id_from_db(policy_id_2, prisma_client)
if policy1 is None:
from fastapi import HTTPException
raise HTTPException(
status_code=404, detail=f"Policy with ID {policy_id_1} not found"
)
if policy2 is None:
from fastapi import HTTPException
raise HTTPException(
status_code=404, detail=f"Policy with ID {policy_id_2} not found"
)
# Helper to compare lists
def compare_lists(list1: List[str], list2: List[str]) -> Dict[str, Any]:
set1, set2 = set(list1), set(list2)
return {
"added": sorted(set1 - set2),
"removed": sorted(set2 - set1),
"unchanged": sorted(set1 & set2),
}
# Build differences
differences: Dict[str, Any] = {}
# Compare guardrails_add
if policy1.guardrails_add != policy2.guardrails_add:
differences["guardrails_add"] = compare_lists(
policy1.guardrails_add, policy2.guardrails_add
)
# Compare guardrails_remove
if policy1.guardrails_remove != policy2.guardrails_remove:
differences["guardrails_remove"] = compare_lists(
policy1.guardrails_remove, policy2.guardrails_remove
)
# Compare description
if policy1.description != policy2.description:
differences["description"] = {
"changed": True,
"new": policy1.description,
"old": policy2.description,
}
# Compare inherit
if policy1.inherit != policy2.inherit:
differences["inherit"] = {
"changed": True,
"new": policy1.inherit,
"old": policy2.inherit,
}
# Compare condition
if policy1.condition != policy2.condition:
differences["condition"] = {
"changed": True,
"new": policy1.condition,
"old": policy2.condition,
}
# Compare pipeline
if policy1.pipeline != policy2.pipeline:
differences["pipeline"] = {
"changed": True,
"new": policy1.pipeline,
"old": policy2.pipeline,
}
return {
"policy_1": {
"policy_id": policy1.policy_id,
"policy_name": policy1.policy_name,
"version_number": policy1.version_number,
"version_status": policy1.version_status,
},
"policy_2": {
"policy_id": policy2.policy_id,
"policy_name": policy2.policy_name,
"version_number": policy2.version_number,
"version_status": policy2.version_status,
},
"differences": differences,
}
except Exception as e:
verbose_proxy_logger.exception(f"Error comparing policy versions: {e}")
raise
# Global singleton instance
_policy_registry: Optional[PolicyRegistry] = None

View file

@ -963,20 +963,36 @@ model LiteLLM_SkillsTable {
updated_by String?
}
// Policy table for storing guardrail policies
// Policy table for storing guardrail policies with versioning support
model LiteLLM_PolicyTable {
policy_id String @id @default(uuid())
policy_name String @unique
policy_name String // Policy name (multiple versions can share same name)
inherit String? // Name of parent policy to inherit from
description String?
guardrails_add String[] @default([])
guardrails_remove String[] @default([])
condition Json? @default("{}") // Policy conditions (e.g., model matching)
pipeline Json? // Optional guardrail pipeline (mode + steps[])
// Versioning fields
version_number Int @default(1)
version_status String @default("production") // "draft", "published", or "production"
parent_version_id String? // Reference to the policy version this was created from
is_latest Boolean @default(true)
published_at DateTime? // When this version was published
production_at DateTime? // When this version was promoted to production
created_at DateTime @default(now())
created_by String?
updated_at DateTime @default(now()) @updatedAt
updated_by String?
// Relations
mirroring_configs LiteLLM_PolicyMirroringTable[]
@@unique([policy_name, version_number])
@@index([policy_name, version_status])
@@index([version_status])
}
// Policy attachment table for defining where policies apply
@ -994,6 +1010,53 @@ model LiteLLM_PolicyAttachmentTable {
updated_by String?
}
// Policy mirroring configuration for silent/shadow testing
model LiteLLM_PolicyMirroringTable {
mirroring_id String @id @default(uuid())
policy_id String // References LiteLLM_PolicyTable.policy_id
traffic_percentage Float @default(0.0) // 0.0-100.0
max_requests Int? // Optional limit on total mirrored requests
current_requests Int @default(0)
enabled Boolean @default(true)
fail_silently Boolean @default(true) // Don't block requests if mirroring fails
log_results Boolean @default(true) // Store results in mirror logs
started_at DateTime @default(now())
expires_at DateTime? // Optional expiration time for mirroring
created_at DateTime @default(now())
created_by String?
updated_at DateTime @default(now()) @updatedAt
updated_by String?
// Relations
policy LiteLLM_PolicyTable @relation(fields: [policy_id], references: [policy_id])
mirror_logs LiteLLM_PolicyMirrorLogs[]
@@index([policy_id, enabled])
@@index([expires_at])
}
// Policy mirroring execution logs
model LiteLLM_PolicyMirrorLogs {
log_id String @id @default(uuid())
mirroring_id String // References LiteLLM_PolicyMirroringTable.mirroring_id
request_id String? // Original request ID if available
executed_at DateTime @default(now())
execution_time_ms Float? // How long the mirrored policy execution took
result String // "pass", "fail", "error"
terminal_action String? // Final action taken by policy (allow/block/modify)
step_results Json? // Detailed step-by-step results
error_message String? // Error details if result = "error"
metadata Json? @default("{}")
created_at DateTime @default(now())
// Relations
mirroring_config LiteLLM_PolicyMirroringTable @relation(fields: [mirroring_id], references: [mirroring_id])
@@index([mirroring_id, executed_at])
@@index([result])
@@index([executed_at])
}
//Unified Access Groups table for storing unified access groups
model LiteLLM_AccessGroupTable {
access_group_id String @id @default(uuid())

View file

@ -10,7 +10,7 @@ from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
VALID_PIPELINE_ACTIONS = {"allow", "block", "next", "modify_response"}
VALID_PIPELINE_ACTIONS = {"allow", "block", "next", "skip_to_end", "modify_response"}
VALID_PIPELINE_MODES = {"pre_call", "post_call"}
@ -24,11 +24,11 @@ class PipelineStep(BaseModel):
guardrail: str = Field(description="Name of the guardrail to run.")
on_fail: str = Field(
default="block",
description="Action when guardrail rejects: next | block | allow | modify_response",
description="Action when guardrail rejects: next | block | allow | skip_to_end | modify_response",
)
on_pass: str = Field(
default="allow",
description="Action when guardrail passes: next | block | allow | modify_response",
description="Action when guardrail passes: next | block | allow | skip_to_end | modify_response",
)
pass_data: bool = Field(
default=False,

View file

@ -27,14 +27,76 @@ Key concepts:
- `policies`: Define WHAT guardrails to apply (with inheritance via `inherit` and `guardrails.add`/`remove`)
- `policy_attachments`: Define WHERE policies apply (teams, keys, models)
- `condition`: Optional model condition for when guardrails apply
- `versioning`: Policies support draft/published/production workflow for safe iteration
"""
from datetime import datetime
from enum import Enum
from typing import Dict, List, Optional, Union
from pydantic import BaseModel, ConfigDict, Field
from litellm.types.proxy.policy_engine.pipeline_types import GuardrailPipeline
# ─────────────────────────────────────────────────────────────────────────────
# Policy Versioning
# ─────────────────────────────────────────────────────────────────────────────
class PolicyVersionStatus(str, Enum):
"""
Status of a policy version in the version workflow.
- DRAFT: Policy is being edited, not yet published
- PUBLISHED: Policy is ready for testing/mirroring
- PRODUCTION: Policy is active and applied to matching requests
"""
DRAFT = "draft"
PUBLISHED = "published"
PRODUCTION = "production"
class SilentMirroringConfig(BaseModel):
"""
Configuration for silent/shadow testing of policy versions.
Silent mirroring executes a policy version on a percentage of traffic
without blocking requests, allowing safe testing before promotion.
Example usage:
- Test new policy on 10% of traffic
- Limit to 1000 test executions
- Log all results for analysis
- Automatically expire after testing period
"""
traffic_percentage: float = Field(
default=0.0,
ge=0.0,
le=100.0,
description="Percentage of traffic to mirror (0.0-100.0)",
)
max_requests: Optional[int] = Field(
default=None,
description="Maximum number of requests to mirror (optional limit)",
)
fail_silently: bool = Field(
default=True,
description="Continue request processing if mirroring fails",
)
log_results: bool = Field(
default=True,
description="Store execution results in mirror logs",
)
expires_at: Optional[datetime] = Field(
default=None,
description="Optional expiration time for mirroring",
)
model_config = ConfigDict(extra="forbid")
# ─────────────────────────────────────────────────────────────────────────────
# Policy Condition
# ─────────────────────────────────────────────────────────────────────────────
@ -182,6 +244,12 @@ class Policy(BaseModel):
Policies can have a `condition` for model-based guardrail application.
Versioning Support:
- Policies support version workflow: draft → published → production
- Multiple versions of the same policy can coexist
- Only production versions are applied to requests by default
- Published versions can be tested via silent mirroring
Example configuration:
```yaml
policies:
@ -238,6 +306,36 @@ class Policy(BaseModel):
description="Optional pipeline for ordered, conditional guardrail execution.",
)
# Versioning fields (populated from database)
version_number: int = Field(
default=1,
description="Version number of this policy (1-indexed)",
)
version_status: PolicyVersionStatus = Field(
default=PolicyVersionStatus.PRODUCTION,
description="Status of this policy version (draft/published/production)",
)
parent_version_id: Optional[str] = Field(
default=None,
description="Policy ID of the version this was created from",
)
is_latest: bool = Field(
default=True,
description="Whether this is the latest version of the policy",
)
published_at: Optional[datetime] = Field(
default=None,
description="When this version was published",
)
production_at: Optional[datetime] = Field(
default=None,
description="When this version was promoted to production",
)
silent_mirroring: Optional[SilentMirroringConfig] = Field(
default=None,
description="Silent mirroring configuration (for testing)",
)
model_config = ConfigDict(extra="forbid")

View file

@ -10,6 +10,8 @@ from typing import Any, Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field
from litellm.types.proxy.policy_engine.policy_types import PolicyVersionStatus
class PolicyMatchContext(BaseModel):
"""
@ -158,6 +160,10 @@ class PolicyCreateRequest(BaseModel):
default=None,
description="Optional guardrail pipeline for ordered execution. Contains 'mode' and 'steps'.",
)
version_status: PolicyVersionStatus = Field(
default=PolicyVersionStatus.DRAFT,
description="Initial status for the policy version (default: draft).",
)
class PolicyUpdateRequest(BaseModel):
@ -212,6 +218,25 @@ class PolicyDBResponse(BaseModel):
pipeline: Optional[Dict[str, Any]] = Field(
default=None, description="Optional guardrail pipeline."
)
# Versioning fields
version_number: int = Field(default=1, description="Version number (1-indexed).")
version_status: str = Field(
default="production", description="Status: draft, published, or production."
)
parent_version_id: Optional[str] = Field(
default=None, description="Policy ID this version was created from."
)
is_latest: bool = Field(
default=True, description="Whether this is the latest version."
)
published_at: Optional[datetime] = Field(
default=None, description="When this version was published."
)
production_at: Optional[datetime] = Field(
default=None, description="When this version was promoted to production."
)
created_at: Optional[datetime] = Field(
default=None, description="When the policy was created."
)
@ -311,6 +336,10 @@ class PipelineTestRequest(BaseModel):
test_messages: List[Dict[str, str]] = Field(
description="Test messages to run through the pipeline, e.g. [{'role': 'user', 'content': '...'}].",
)
guardrail_filter: Optional[List[str]] = Field(
default=None,
description="Optional list of guardrail names to test. If provided, only these guardrails will be executed.",
)
class PolicyResolveRequest(BaseModel):

View file

@ -5805,6 +5805,126 @@ export const getPolicyInfo = async (accessToken: string, policyId: string) => {
}
};
// Policy Version Management API Calls
export const createPolicyVersion = async (accessToken: string, policyId: string) => {
try {
const url = proxyBaseUrl ? `${proxyBaseUrl}/policies/${policyId}/versions` : `/policies/${policyId}/versions`;
const response = await fetch(url, {
method: "POST",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to create policy version:", error);
throw error;
}
};
export const listPolicyVersions = async (accessToken: string, policyName: string) => {
try {
const url = proxyBaseUrl
? `${proxyBaseUrl}/policies/name/${encodeURIComponent(policyName)}/versions`
: `/policies/name/${encodeURIComponent(policyName)}/versions`;
const response = await fetch(url, {
method: "GET",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to list policy versions:", error);
throw error;
}
};
export const updatePolicyVersionStatus = async (
accessToken: string,
policyId: string,
status: "draft" | "published" | "production"
) => {
try {
const url = proxyBaseUrl
? `${proxyBaseUrl}/policies/${policyId}/status?status=${status}`
: `/policies/${policyId}/status?status=${status}`;
const response = await fetch(url, {
method: "PUT",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to update policy version status:", error);
throw error;
}
};
export const comparePolicyVersions = async (
accessToken: string,
policyId: string,
compareWithId: string
) => {
try {
const url = proxyBaseUrl
? `${proxyBaseUrl}/policies/${policyId}/compare/${compareWithId}`
: `/policies/${policyId}/compare/${compareWithId}`;
const response = await fetch(url, {
method: "GET",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to compare policy versions:", error);
throw error;
}
};
// Policy Attachments API Calls
export const getPolicyAttachmentsList = async (accessToken: string) => {

View file

@ -3,9 +3,11 @@ import { Form, Select, Modal, Divider, Typography, Tag, Alert, Radio } from "ant
import { Button, TextInput, Textarea } from "@tremor/react";
import { Policy, PolicyCreateRequest, PolicyUpdateRequest } from "./types";
import { Guardrail } from "../guardrails/types";
import { getResolvedGuardrails, modelAvailableCall } from "../networking";
import { getResolvedGuardrails, modelAvailableCall, createPolicyVersion } from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import VersionStatusBadge from "./version_status_badge";
import VersionSidebar from "./version_sidebar";
const { Text } = Typography;
const { Option } = Select;
@ -21,6 +23,9 @@ interface AddPolicyFormProps {
availableGuardrails: Guardrail[];
createPolicy: (accessToken: string, policyData: any) => Promise<any>;
updatePolicy: (accessToken: string, policyId: string, policyData: any) => Promise<any>;
onVersionSelect?: (version: Policy) => void;
onVersionCreated?: () => void;
onOpenSimulator?: (policy: Policy) => void;
}
// ─────────────────────────────────────────────────────────────────────────────
@ -140,6 +145,9 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
availableGuardrails,
createPolicy,
updatePolicy,
onVersionSelect,
onVersionCreated,
onOpenSimulator,
}) => {
const [form] = Form.useForm();
const [isSubmitting, setIsSubmitting] = useState(false);
@ -173,14 +181,13 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
loadResolvedGuardrails(editingPolicy.policy_id);
}
// If editing a pipeline policy, go directly to flow builder
if (editingPolicy.pipeline) {
onClose();
// When editing any policy, open in flow builder instead of the form.
// Only call onOpenFlowBuilder (not onClose) so the parent keeps editingPolicy
// and the flow builder can pre-populate from it.
if (editingPolicy.policy_id) {
onOpenFlowBuilder();
return;
}
// If editing a simple policy, skip mode picker
setStep("simple_form");
} else if (visible) {
form.resetFields();
setResolvedGuardrails([]);
@ -288,6 +295,34 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
}
};
const handleCreateNewVersion = async () => {
if (!accessToken || !editingPolicy?.policy_id) return;
Modal.confirm({
title: "Create New Version",
content: "This will create a new draft version based on the current policy. You can then edit the new version.",
okText: "Create",
cancelText: "Cancel",
onOk: async () => {
setIsSubmitting(true);
try {
await createPolicyVersion(accessToken, editingPolicy.policy_id);
NotificationsManager.success("New version created successfully");
resetForm();
onSuccess();
onClose();
} catch (error) {
console.error("Failed to create version:", error);
NotificationsManager.fromBackend(
"Failed to create version: " + (error instanceof Error ? error.message : String(error))
);
} finally {
setIsSubmitting(false);
}
},
});
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
@ -352,6 +387,7 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
footer={null}
width={620}
>
<Form form={form} style={{ display: "none" }} />
<ModePicker selected={selectedMode} onSelect={setSelectedMode} />
{selectedMode === "flow_builder" && (
@ -386,14 +422,61 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
}
// ── Simple Form Step ──────────────────────────────────────────────────────
return (
<Modal
title={isEditing ? "Edit Policy" : "Create New Policy"}
open={visible}
onCancel={handleClose}
footer={null}
width={700}
>
const versionStatus = editingPolicy?.version_status || "draft";
const versionNumber = editingPolicy?.version_number || 1;
const isReadOnlyVersion = isEditing && versionStatus !== "draft";
const showVersionSidebar = isEditing && editingPolicy && onVersionSelect && onVersionCreated;
const formContent = (
<>
{/* Version Indicator (compact when sidebar is shown) */}
{isEditing && editingPolicy && (
<div className="mb-4">
<div className="flex items-center justify-between p-3 rounded-lg bg-blue-50 border border-blue-200">
<div className="flex items-center gap-3">
<div>
<Text strong style={{ fontSize: 14, display: "block" }}>
Version {versionNumber}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{editingPolicy.policy_name}
</Text>
</div>
<VersionStatusBadge status={versionStatus as any} size="sm" />
</div>
<div className="flex items-center gap-2">
{onOpenSimulator && (
<Button
size="xs"
variant="secondary"
onClick={() => onOpenSimulator(editingPolicy)}
>
Test in Simulator
</Button>
)}
{(versionStatus === "published" || versionStatus === "production") && (
<Button
size="xs"
onClick={handleCreateNewVersion}
loading={isSubmitting}
>
Create New Version
</Button>
)}
</div>
</div>
{isReadOnlyVersion && (
<Alert
message="Read-Only Mode"
description="This is a published or production version. Create a new version to make changes."
type="info"
showIcon
style={{ marginTop: 12 }}
/>
)}
</div>
)}
<Form
form={form}
layout="vertical"
@ -425,6 +508,7 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
<Textarea
rows={2}
placeholder="Describe what this policy does..."
disabled={isReadOnlyVersion}
/>
</Form.Item>
@ -442,6 +526,7 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
placeholder="Select a parent policy (optional)"
options={policyOptions}
style={{ width: "100%" }}
disabled={isReadOnlyVersion}
/>
</Form.Item>
@ -460,6 +545,7 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
placeholder="Select guardrails to add"
options={guardrailOptions}
style={{ width: "100%" }}
disabled={isReadOnlyVersion}
/>
</Form.Item>
@ -474,6 +560,7 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
placeholder="Select guardrails to remove (from inherited)"
options={guardrailOptions}
style={{ width: "100%" }}
disabled={isReadOnlyVersion}
/>
</Form.Item>
@ -519,6 +606,7 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
setModelConditionType(e.target.value);
form.setFieldValue("model_condition", undefined);
}}
disabled={isReadOnlyVersion}
>
<Radio value="model">Select Model</Radio>
<Radio value="regex">Custom Regex Pattern</Radio>
@ -547,21 +635,58 @@ const AddPolicyForm: React.FC<AddPolicyFormProps> = ({
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
}
style={{ width: "100%" }}
disabled={isReadOnlyVersion}
/>
) : (
<TextInput placeholder="Leave empty to apply to all models (e.g., gpt-4.* or bedrock/claude-.*)" />
<TextInput
placeholder="Leave empty to apply to all models (e.g., gpt-4.* or bedrock/claude-.*)"
disabled={isReadOnlyVersion}
/>
)}
</Form.Item>
<div className="flex justify-end space-x-2 mt-4">
<Button variant="secondary" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={isSubmitting}>
{isEditing ? "Update Policy" : "Create Policy"}
{isReadOnlyVersion ? "Close" : "Cancel"}
</Button>
{!isReadOnlyVersion && (
<Button onClick={handleSubmit} loading={isSubmitting}>
{isEditing ? "Update Policy" : "Create Policy"}
</Button>
)}
</div>
</Form>
</>
);
return (
<Modal
title={isEditing ? "Edit Policy" : "Create New Policy"}
open={visible}
onCancel={handleClose}
footer={null}
width={showVersionSidebar ? 960 : 700}
>
{showVersionSidebar && editingPolicy ? (
<div className="flex gap-6" style={{ minHeight: 400 }}>
<div style={{ width: 260, flexShrink: 0 }}>
<VersionSidebar
policyName={editingPolicy.policy_name}
currentPolicyId={editingPolicy.policy_id}
accessToken={accessToken}
onVersionSelect={(version) => onVersionSelect?.(version)}
onVersionCreated={() => {
onVersionCreated?.();
}}
/>
</div>
<div className="flex-1 overflow-y-auto" style={{ maxHeight: "70vh" }}>
{formContent}
</div>
</div>
) : (
formContent
)}
</Modal>
);
};

View file

@ -68,6 +68,7 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
const [loadedTemplates, setLoadedTemplates] = useState<any[]>([]);
const [templateQueue, setTemplateQueue] = useState<any[]>([]);
const [templateQueueProgress, setTemplateQueueProgress] = useState<{ current: number; total: number } | null>(null);
const [preselectedPolicyName, setPreselectedPolicyName] = useState<string | null>(null);
const isAdmin = userRole ? isAdminRole(userRole) : false;
@ -494,6 +495,13 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
availableGuardrails={guardrailsList}
createPolicy={createPolicyCall}
updatePolicy={updatePolicyCall}
onVersionSelect={(version) => setEditingPolicy(version)}
onVersionCreated={() => fetchPolicies()}
onOpenSimulator={(policy) => {
handleCloseModal();
setActiveTab(3);
setPreselectedPolicyName(policy?.policy_name ?? null);
}}
/>
<DeleteResourceModal
@ -602,7 +610,11 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
</TabPanel>
<TabPanel>
<PolicyTestPanel accessToken={accessToken} />
<PolicyTestPanel
accessToken={accessToken}
preselectedPolicyName={preselectedPolicyName}
onClearPreselectedPolicy={() => setPreselectedPolicyName(null)}
/>
</TabPanel>
</TabPanels>
</TabGroup>
@ -643,6 +655,8 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({
availableGuardrails={guardrailsList}
createPolicy={createPolicyCall}
updatePolicy={updatePolicyCall}
isAdmin={isAdmin}
onGuardrailUpdated={fetchGuardrails}
/>
)}
</div>

View file

@ -1,12 +1,13 @@
import React, { useState } from "react";
import { Select, Typography, message } from "antd";
import { Select, Typography, message, Modal } from "antd";
import { Button, TextInput } from "@tremor/react";
import { ArrowLeftIcon, PlusIcon } from "@heroicons/react/outline";
import { DotsVerticalIcon } from "@heroicons/react/solid";
import { DotsVerticalIcon, PencilIcon, BeakerIcon } from "@heroicons/react/solid";
import { GuardrailPipeline, PipelineStep, PipelineTestResult, PolicyCreateRequest, PolicyUpdateRequest, Policy } from "./types";
import { Guardrail } from "../guardrails/types";
import { testPipelineCall } from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
import GuardrailInfoView from "../guardrails/guardrail_info";
const { Text } = Typography;
@ -34,6 +35,18 @@ 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;
const add = policy.guardrails_add || [];
if (add.length === 0) return { mode: "pre_call", steps: [createDefaultStep()] };
return {
mode: "pre_call",
steps: add.map((guardrail) => ({ ...createDefaultStep(), guardrail })),
};
}
function insertStep(steps: PipelineStep[], atIndex: number): PipelineStep[] {
const newSteps = [...steps];
newSteps.splice(atIndex, 0, createDefaultStep());
@ -162,6 +175,8 @@ interface StepCardProps {
onChange: (updated: Partial<PipelineStep>) => void;
onDelete: () => void;
availableGuardrails: Guardrail[];
onEditGuardrail: (guardrailName: string) => void;
onTestGuardrail: (guardrailName: string) => void;
}
const StepCard: React.FC<StepCardProps> = ({
@ -171,12 +186,16 @@ const StepCard: React.FC<StepCardProps> = ({
onChange,
onDelete,
availableGuardrails,
onEditGuardrail,
onTestGuardrail,
}) => {
const guardrailOptions = availableGuardrails.map((g) => ({
label: g.guardrail_name || g.guardrail_id,
value: g.guardrail_name || g.guardrail_id,
}));
const selectedGuardrail = step.guardrail;
return (
<div
style={{
@ -230,22 +249,84 @@ const StepCard: React.FC<StepCardProps> = ({
</div>
</div>
{/* Guardrail selector */}
{/* Guardrail selector with action buttons */}
<div style={{ padding: "12px 20px 16px 20px" }}>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Guardrail
</label>
<Select
showSearch
style={{ width: "100%" }}
placeholder="Select a guardrail"
value={step.guardrail || undefined}
onChange={(value) => onChange({ guardrail: value })}
options={guardrailOptions}
filterOption={(input, option) =>
(option?.label ?? "").toString().toLowerCase().includes(input.toLowerCase())
}
/>
<div className="flex items-center gap-2">
<Select
showSearch
style={{ flex: 1 }}
placeholder="Select a guardrail"
value={step.guardrail || undefined}
onChange={(value) => onChange({ guardrail: value })}
options={guardrailOptions}
filterOption={(input, option) =>
(option?.label ?? "").toString().toLowerCase().includes(input.toLowerCase())
}
/>
{selectedGuardrail && (
<>
<button
onClick={() => onEditGuardrail(selectedGuardrail)}
style={{
padding: "6px 12px",
border: "1px solid #d1d5db",
borderRadius: 6,
backgroundColor: "#fff",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 4,
fontSize: 13,
color: "#374151",
transition: "all 0.15s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "#6366f1";
e.currentTarget.style.backgroundColor = "#eef2ff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "#d1d5db";
e.currentTarget.style.backgroundColor = "#fff";
}}
title="Edit guardrail settings"
>
<PencilIcon style={{ width: 14, height: 14 }} />
Edit
</button>
<button
onClick={() => onTestGuardrail(selectedGuardrail)}
style={{
padding: "6px 12px",
border: "1px solid #d1d5db",
borderRadius: 6,
backgroundColor: "#fff",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 4,
fontSize: 13,
color: "#374151",
transition: "all 0.15s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "#6366f1";
e.currentTarget.style.backgroundColor = "#eef2ff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "#d1d5db";
e.currentTarget.style.backgroundColor = "#fff";
}}
title="Test this guardrail"
>
<BeakerIcon style={{ width: 14, height: 14 }} />
Test
</button>
</>
)}
</div>
</div>
{/* ON PASS section */}
@ -317,13 +398,22 @@ interface PipelineFlowBuilderProps {
pipeline: GuardrailPipeline;
onChange: (pipeline: GuardrailPipeline) => void;
availableGuardrails: Guardrail[];
accessToken: string | null;
isAdmin: boolean;
onGuardrailUpdated?: () => void;
}
const PipelineFlowBuilder: React.FC<PipelineFlowBuilderProps> = ({
pipeline,
onChange,
availableGuardrails,
accessToken,
isAdmin,
onGuardrailUpdated,
}) => {
const [editingGuardrailName, setEditingGuardrailName] = useState<string | null>(null);
const [testingGuardrailName, setTestingGuardrailName] = useState<string | null>(null);
const handleInsertStep = (atIndex: number) => {
onChange({ ...pipeline, steps: insertStep(pipeline.steps, atIndex) });
};
@ -339,6 +429,34 @@ const PipelineFlowBuilder: React.FC<PipelineFlowBuilderProps> = ({
});
};
const handleEditGuardrail = (guardrailName: string) => {
setEditingGuardrailName(guardrailName);
};
const handleTestGuardrail = (guardrailName: string) => {
setTestingGuardrailName(guardrailName);
};
const handleCloseEditModal = () => {
setEditingGuardrailName(null);
if (onGuardrailUpdated) {
onGuardrailUpdated();
}
};
const handleCloseTestModal = () => {
setTestingGuardrailName(null);
};
// Find the guardrail ID for the selected guardrail name
const getGuardrailId = (guardrailName: string | null): string | null => {
if (!guardrailName) return null;
const guardrail = availableGuardrails.find(
(g) => g.guardrail_name === guardrailName || g.guardrail_id === guardrailName
);
return guardrail?.guardrail_id || null;
};
return (
<div className="flex flex-col items-center" style={{ padding: "16px 0" }}>
{/* Trigger Card */}
@ -389,6 +507,8 @@ const PipelineFlowBuilder: React.FC<PipelineFlowBuilderProps> = ({
onChange={(updated) => handleUpdateStep(index, updated)}
onDelete={() => handleRemoveStep(index)}
availableGuardrails={availableGuardrails}
onEditGuardrail={handleEditGuardrail}
onTestGuardrail={handleTestGuardrail}
/>
</React.Fragment>
))}
@ -448,6 +568,63 @@ const PipelineFlowBuilder: React.FC<PipelineFlowBuilderProps> = ({
</div>
</div>
</div>
{/* Edit Guardrail Modal */}
<Modal
title="Edit Guardrail"
open={!!editingGuardrailName}
onCancel={handleCloseEditModal}
footer={null}
width={900}
destroyOnHidden
zIndex={1200}
styles={{ body: { padding: 0, maxHeight: "80vh", overflowY: "auto" } }}
>
{editingGuardrailName && getGuardrailId(editingGuardrailName) && (
<GuardrailInfoView
guardrailId={getGuardrailId(editingGuardrailName)!}
onClose={handleCloseEditModal}
accessToken={accessToken}
isAdmin={isAdmin}
/>
)}
</Modal>
{/* Test Guardrail Modal */}
<Modal
title="Test Guardrail"
open={!!testingGuardrailName}
onCancel={handleCloseTestModal}
footer={null}
width={1200}
destroyOnHidden
zIndex={1200}
styles={{ body: { padding: "16px", maxHeight: "80vh", overflowY: "auto" } }}
>
{testingGuardrailName && (
<div>
<Text style={{ fontSize: 14, color: "#6b7280", marginBottom: 16, display: "block" }}>
Testing guardrail: <strong>{testingGuardrailName}</strong>
</Text>
<PipelineTestPanel
pipeline={{
mode: pipeline.mode,
steps: [
{
guardrail: testingGuardrailName,
on_pass: "next",
on_fail: "block",
pass_data: false,
modify_response_message: null,
},
],
}}
accessToken={accessToken}
onClose={handleCloseTestModal}
/>
</div>
)}
</Modal>
</div>
);
};
@ -795,6 +972,8 @@ interface FlowBuilderPageProps {
availableGuardrails: Guardrail[];
createPolicy: (accessToken: string, policyData: any) => Promise<any>;
updatePolicy: (accessToken: string, policyId: string, policyData: any) => Promise<any>;
isAdmin: boolean;
onGuardrailUpdated?: () => void;
}
export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
@ -805,6 +984,8 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
availableGuardrails,
createPolicy,
updatePolicy,
isAdmin,
onGuardrailUpdated,
}) => {
const isEditing = !!editingPolicy?.policy_id;
@ -812,8 +993,8 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
const [description, setDescription] = useState(editingPolicy?.description || "");
const [isSubmitting, setIsSubmitting] = useState(false);
const [showTestPanel, setShowTestPanel] = useState(false);
const [pipeline, setPipeline] = useState<GuardrailPipeline>(
editingPolicy?.pipeline || { mode: "pre_call", steps: [createDefaultStep()] }
const [pipeline, setPipeline] = useState<GuardrailPipeline>(() =>
getInitialPipelineFromPolicy(editingPolicy)
);
const handleSave = async () => {
@ -875,10 +1056,11 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
right: 0,
bottom: 0,
backgroundColor: "#f9fafb",
zIndex: 1000,
zIndex: 1100,
display: "flex",
flexDirection: "column",
overflow: "hidden",
pointerEvents: "auto",
}}
>
{/* Header bar */}
@ -964,10 +1146,11 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
</div>
{/* Flow builder canvas + test panel */}
<div style={{ flex: 1, display: "flex", overflow: "hidden" }}>
<div style={{ flex: 1, display: "flex", overflow: "hidden", minHeight: 0 }}>
<div
style={{
flex: 1,
minHeight: 0,
overflowY: "auto",
display: "flex",
justifyContent: "center",
@ -979,6 +1162,9 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
pipeline={pipeline}
onChange={setPipeline}
availableGuardrails={availableGuardrails}
accessToken={accessToken}
isAdmin={isAdmin}
onGuardrailUpdated={onGuardrailUpdated}
/>
</div>
</div>

View file

@ -48,7 +48,7 @@ const PolicyTemplateCard: React.FC<PolicyTemplateCardProps> = ({
return (
<Card
className="h-full hover:shadow-md transition-shadow"
bodyStyle={{ display: "flex", flexDirection: "column", height: "100%" }}
styles={{ body: { display: "flex", flexDirection: "column", height: "100%" } }}
>
<div className="flex items-start justify-between mb-4">
<div className={`p-2 rounded-lg ${iconBg}`}>
@ -196,7 +196,9 @@ const PolicyTemplates: React.FC<PolicyTemplatesProps> = ({ onUseTemplate, onOpen
if (isLoading) {
return (
<div className="flex justify-center items-center py-20">
<Spin size="large" tip="Loading policy templates..." />
<Spin size="large" tip="Loading policy templates...">
<div className="min-h-[200px]" />
</Spin>
</div>
);
}

View file

@ -8,6 +8,8 @@ const { Text } = Typography;
interface PolicyTestPanelProps {
accessToken: string | null;
preselectedPolicyName?: string | null;
onClearPreselectedPolicy?: () => void;
}
interface PolicyMatchDetail {
@ -21,7 +23,11 @@ interface ResolveResult {
matched_policies: PolicyMatchDetail[];
}
const PolicyTestPanel: React.FC<PolicyTestPanelProps> = ({ accessToken }) => {
const PolicyTestPanel: React.FC<PolicyTestPanelProps> = ({
accessToken,
preselectedPolicyName,
onClearPreselectedPolicy,
}) => {
const [form] = Form.useForm();
const [isLoading, setIsLoading] = useState(false);
const [result, setResult] = useState<ResolveResult | null>(null);
@ -103,6 +109,16 @@ const PolicyTestPanel: React.FC<PolicyTestPanelProps> = ({ accessToken }) => {
return (
<div>
{preselectedPolicyName && (
<Alert
message={`Viewing results for policy: ${preselectedPolicyName}`}
type="info"
showIcon
closable={!!onClearPreselectedPolicy}
onClose={onClearPreselectedPolicy}
className="mb-4"
/>
)}
<div className="bg-white border rounded-lg p-6 mb-6">
<div className="mb-5">
<h3 className="text-base font-semibold mb-1">Policy Simulator</h3>

View file

@ -11,6 +11,13 @@ export interface Policy {
updated_at?: string;
created_by?: string;
updated_by?: string;
// Versioning fields
version_number?: number;
version_status?: "draft" | "published" | "production";
parent_version_id?: string | null;
is_latest?: boolean;
published_at?: string | null;
production_at?: string | null;
}
export interface PolicyCondition {
@ -99,3 +106,45 @@ export interface PipelineTestResult {
error_message: string | null;
modify_response_message: string | null;
}
// Version Management Types
export interface PolicyVersionListResponse {
policies: Policy[];
total_count: number;
}
export interface FieldDifference<T = any> {
changed: boolean;
old: T | null;
new: T | null;
}
export interface ArrayFieldDifference {
added: string[];
removed: string[];
unchanged: string[];
}
export interface PolicyVersionComparison {
policy_1: {
policy_id: string;
policy_name: string;
version_number: number;
version_status: string;
};
policy_2: {
policy_id: string;
policy_name: string;
version_number: number;
version_status: string;
};
differences: {
description?: FieldDifference<string>;
inherit?: FieldDifference<string>;
guardrails_add?: ArrayFieldDifference;
guardrails_remove?: ArrayFieldDifference;
condition?: FieldDifference<PolicyCondition>;
pipeline?: FieldDifference<GuardrailPipeline>;
};
}

View file

@ -0,0 +1,322 @@
import React, { useState, useEffect } from "react";
import { Card } from "@tremor/react";
import { Typography, Spin, Tag, Divider, Alert } from "antd";
import { PolicyVersionComparison } from "./types";
import VersionStatusBadge from "./version_status_badge";
import { comparePolicyVersions } from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
const { Title, Text } = Typography;
interface VersionComparisonProps {
policyId1: string;
policyId2: string;
accessToken: string | null;
}
const VersionComparison: React.FC<VersionComparisonProps> = ({
policyId1,
policyId2,
accessToken,
}) => {
const [comparison, setComparison] = useState<PolicyVersionComparison | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadComparison = async () => {
if (!accessToken || !policyId1 || !policyId2) return;
setIsLoading(true);
try {
const data = await comparePolicyVersions(accessToken, policyId1, policyId2);
setComparison(data);
} catch (error) {
console.error("Failed to compare versions:", error);
NotificationsManager.fromBackend("Failed to compare policy versions");
} finally {
setIsLoading(false);
}
};
loadComparison();
}, [policyId1, policyId2, accessToken]);
if (isLoading) {
return (
<div className="flex justify-center items-center p-12">
<Spin size="large" />
</div>
);
}
if (!comparison) {
return (
<Alert
message="Failed to load comparison"
description="Could not compare the selected versions."
type="error"
showIcon
/>
);
}
const { policy_1, policy_2, differences } = comparison;
const renderFieldComparison = (
label: string,
oldValue: any,
newValue: any,
changed: boolean
) => {
if (!changed) {
return (
<div className="mb-4">
<Text strong style={{ fontSize: 14, display: "block", marginBottom: 8 }}>
{label}
</Text>
<div className="p-3 rounded bg-gray-50 border border-gray-200">
<Text style={{ fontSize: 13 }}>{oldValue || "(Not set)"}</Text>
</div>
</div>
);
}
return (
<div className="mb-4">
<Text strong style={{ fontSize: 14, display: "block", marginBottom: 8 }}>
{label}
</Text>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="mb-2">
<Tag color="red" style={{ fontSize: 11 }}>
v{policy_2.version_number} (Old)
</Tag>
</div>
<div className="p-3 rounded bg-red-50 border border-red-200">
<Text style={{ fontSize: 13 }}>{oldValue || "(Not set)"}</Text>
</div>
</div>
<div>
<div className="mb-2">
<Tag color="green" style={{ fontSize: 11 }}>
v{policy_1.version_number} (New)
</Tag>
</div>
<div className="p-3 rounded bg-green-50 border border-green-200">
<Text style={{ fontSize: 13 }}>{newValue || "(Not set)"}</Text>
</div>
</div>
</div>
</div>
);
};
const renderArrayFieldComparison = (
label: string,
added: string[],
removed: string[],
unchanged: string[]
) => {
const hasChanges = added.length > 0 || removed.length > 0;
if (!hasChanges && unchanged.length === 0) {
return (
<div className="mb-4">
<Text strong style={{ fontSize: 14, display: "block", marginBottom: 8 }}>
{label}
</Text>
<div className="p-3 rounded bg-gray-50 border border-gray-200">
<Text type="secondary" style={{ fontSize: 13 }}>
(None)
</Text>
</div>
</div>
);
}
return (
<div className="mb-4">
<Text strong style={{ fontSize: 14, display: "block", marginBottom: 8 }}>
{label}
</Text>
<div className="p-3 rounded bg-gray-50 border border-gray-200">
<div className="space-y-2">
{added.length > 0 && (
<div>
<Text type="secondary" style={{ fontSize: 12, display: "block", marginBottom: 4 }}>
Added:
</Text>
<div className="flex flex-wrap gap-1">
{added.map((item) => (
<Tag key={item} color="green" style={{ fontSize: 12 }}>
+ {item}
</Tag>
))}
</div>
</div>
)}
{removed.length > 0 && (
<div>
<Text type="secondary" style={{ fontSize: 12, display: "block", marginBottom: 4 }}>
Removed:
</Text>
<div className="flex flex-wrap gap-1">
{removed.map((item) => (
<Tag key={item} color="red" style={{ fontSize: 12 }}>
- {item}
</Tag>
))}
</div>
</div>
)}
{unchanged.length > 0 && (
<div>
<Text type="secondary" style={{ fontSize: 12, display: "block", marginBottom: 4 }}>
Unchanged:
</Text>
<div className="flex flex-wrap gap-1">
{unchanged.map((item) => (
<Tag key={item} color="default" style={{ fontSize: 12 }}>
{item}
</Tag>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
};
return (
<Card>
<div className="space-y-6">
{/* Header */}
<div>
<Title level={4} style={{ marginBottom: 16 }}>
Version Comparison
</Title>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 rounded-lg bg-blue-50 border border-blue-200">
<div className="flex items-center gap-2 mb-2">
<Text strong style={{ fontSize: 14 }}>
Version {policy_1.version_number}
</Text>
<VersionStatusBadge status={policy_1.version_status as any} size="xs" />
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
{policy_1.policy_name}
</Text>
</div>
<div className="p-4 rounded-lg bg-gray-50 border border-gray-200">
<div className="flex items-center gap-2 mb-2">
<Text strong style={{ fontSize: 14 }}>
Version {policy_2.version_number}
</Text>
<VersionStatusBadge status={policy_2.version_status as any} size="xs" />
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
{policy_2.policy_name}
</Text>
</div>
</div>
</div>
<Divider />
{/* Differences */}
<div>
<Text strong style={{ fontSize: 15, display: "block", marginBottom: 16 }}>
Changes
</Text>
{/* Description */}
{differences.description && (
<>
{renderFieldComparison(
"Description",
differences.description.old,
differences.description.new,
differences.description.changed
)}
</>
)}
{/* Inherit */}
{differences.inherit && (
<>
{renderFieldComparison(
"Inherits From",
differences.inherit.old,
differences.inherit.new,
differences.inherit.changed
)}
</>
)}
{/* Guardrails Add */}
{differences.guardrails_add && (
<>
{renderArrayFieldComparison(
"Guardrails to Add",
differences.guardrails_add.added,
differences.guardrails_add.removed,
differences.guardrails_add.unchanged
)}
</>
)}
{/* Guardrails Remove */}
{differences.guardrails_remove && (
<>
{renderArrayFieldComparison(
"Guardrails to Remove",
differences.guardrails_remove.added,
differences.guardrails_remove.removed,
differences.guardrails_remove.unchanged
)}
</>
)}
{/* Condition */}
{differences.condition && (
<>
{renderFieldComparison(
"Model Condition",
differences.condition.old?.model || "(None)",
differences.condition.new?.model || "(None)",
differences.condition.changed
)}
</>
)}
{/* Pipeline */}
{differences.pipeline && (
<>
{renderFieldComparison(
"Pipeline",
differences.pipeline.old ? JSON.stringify(differences.pipeline.old, null, 2) : "(None)",
differences.pipeline.new ? JSON.stringify(differences.pipeline.new, null, 2) : "(None)",
differences.pipeline.changed
)}
</>
)}
{/* No changes message */}
{Object.keys(differences).length === 0 && (
<Alert
message="No differences found"
description="These versions are identical."
type="info"
showIcon
/>
)}
</div>
</div>
</Card>
);
};
export default VersionComparison;

View file

@ -0,0 +1,442 @@
import React, { useState, useEffect } from "react";
import { Button } from "@tremor/react";
import { Spin, Typography, Divider, Tooltip, Modal, message, Select, Form } from "antd";
import {
PlusIcon,
ChevronUpIcon,
ChevronDownIcon,
ClockIcon,
CheckCircleIcon,
SwitchHorizontalIcon,
} from "@heroicons/react/outline";
import { Policy, PolicyVersionListResponse } from "./types";
import VersionStatusBadge from "./version_status_badge";
import VersionComparison from "./version_comparison";
import {
listPolicyVersions,
updatePolicyVersionStatus,
createPolicyVersion,
} from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
const { Text } = Typography;
interface VersionSidebarProps {
policyName: string;
currentPolicyId: string;
accessToken: string | null;
onVersionSelect: (version: Policy) => void;
onVersionCreated: () => void;
}
const VersionSidebar: React.FC<VersionSidebarProps> = ({
policyName,
currentPolicyId,
accessToken,
onVersionSelect,
onVersionCreated,
}) => {
const [versions, setVersions] = useState<Policy[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [compareModalOpen, setCompareModalOpen] = useState(false);
const [comparePolicyId1, setComparePolicyId1] = useState<string>("");
const [comparePolicyId2, setComparePolicyId2] = useState<string>("");
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);
NotificationsManager.fromBackend("Failed to load policy versions");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadVersions();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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);
NotificationsManager.fromBackend(
"Failed to create version: " +
(error instanceof Error ? error.message : String(error))
);
} 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? This makes it ready for testing.`,
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? This will make it the active version. 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 (
<Button
size="xs"
variant="secondary"
icon={ChevronUpIcon}
onClick={() => handlePromoteToPublished(version.policy_id, version.version_number || 1)}
loading={isProcessing}
disabled={!!actionLoading}
>
Publish
</Button>
);
}
if (status === "published") {
return (
<Button
size="xs"
variant="primary"
icon={ChevronUpIcon}
onClick={() =>
handlePromoteToProduction(version.policy_id, version.version_number || 1)
}
loading={isProcessing}
disabled={!!actionLoading}
>
To Production
</Button>
);
}
if (status === "production") {
return (
<Button
size="xs"
variant="secondary"
icon={ChevronDownIcon}
onClick={() => handleDemote(version.policy_id, version.version_number || 1)}
loading={isProcessing}
disabled={!!actionLoading}
>
Demote
</Button>
);
}
return null;
};
if (isLoading) {
return (
<div className="flex justify-center items-center p-8">
<Spin size="default" />
</div>
);
}
return (
<div className="space-y-4">
{/* Header with Create New Version button */}
<div className="flex justify-between items-center">
<Text strong style={{ fontSize: 16 }}>
Versions
</Text>
<Button
size="xs"
icon={PlusIcon}
onClick={handleCreateNewVersion}
loading={actionLoading === "create"}
disabled={!!actionLoading}
>
New Version
</Button>
</div>
<Divider style={{ margin: "12px 0" }} />
{/* Version List */}
<div className="space-y-3">
{versions.length === 0 ? (
<Text type="secondary" style={{ fontSize: 13 }}>
No versions found
</Text>
) : (
versions.map((version) => {
const isActive = version.policy_id === currentPolicyId;
const versionNumber = version.version_number || 1;
const status = version.version_status || "draft";
return (
<div
key={version.policy_id}
className={`p-3 rounded-lg border transition-all cursor-pointer ${
isActive
? "bg-blue-50 border-blue-300 shadow-sm"
: "bg-white border-gray-200 hover:border-gray-300"
}`}
onClick={() => onVersionSelect(version)}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<Text strong style={{ fontSize: 14 }}>
v{versionNumber}
</Text>
{isActive && (
<CheckCircleIcon className="w-4 h-4 text-blue-500" />
)}
</div>
<VersionStatusBadge status={status as any} size="xs" />
</div>
<div className="flex items-center gap-1 mb-2">
<ClockIcon className="w-3 h-3 text-gray-400" />
<Text type="secondary" style={{ fontSize: 12 }}>
{formatDate(version.created_at)}
</Text>
</div>
{version.description && (
<Text
type="secondary"
style={{
fontSize: 12,
display: "block",
marginBottom: 8,
}}
className="line-clamp-2"
>
{version.description}
</Text>
)}
<div className="flex justify-end">
{getActionButtons(version)}
</div>
</div>
);
})
)}
</div>
<Divider style={{ margin: "16px 0" }} />
{/* Compare versions */}
{versions.length >= 2 && (
<div className="mb-4">
<Button
size="xs"
variant="secondary"
icon={SwitchHorizontalIcon}
onClick={() => {
setComparePolicyId1(currentPolicyId);
setComparePolicyId2(
versions.find((v) => v.policy_id !== currentPolicyId)?.policy_id || ""
);
setCompareModalOpen(true);
}}
>
Compare versions
</Button>
</div>
)}
{/* Silent Mirroring - Coming Soon */}
<div
className="p-4 rounded-lg"
style={{
backgroundColor: "#f9fafb",
border: "1px dashed #d1d5db",
}}
>
<div className="flex items-center justify-between mb-2">
<Text strong style={{ fontSize: 14 }}>
Silent Mirroring
</Text>
<span
className="px-2 py-0.5 text-xs font-medium rounded"
style={{
backgroundColor: "#e0e7ff",
color: "#4f46e5",
}}
>
Coming Soon
</span>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
Test policy versions on production traffic without blocking requests.
Shadow testing helps validate changes before full rollout.
</Text>
</div>
{/* Compare versions modal */}
<Modal
title="Compare versions"
open={compareModalOpen}
onCancel={() => {
setCompareModalOpen(false);
setComparePolicyId1("");
setComparePolicyId2("");
}}
footer={null}
width={720}
destroyOnHidden
>
<Form layout="vertical" className="mb-4">
<div className="grid grid-cols-2 gap-4">
<Form.Item label="Version A">
<Select
placeholder="Select version"
value={comparePolicyId1 || undefined}
onChange={setComparePolicyId1}
style={{ width: "100%" }}
options={versions.map((v) => ({
label: `v${v.version_number || 1} (${v.version_status || "draft"})`,
value: v.policy_id,
}))}
/>
</Form.Item>
<Form.Item label="Version B">
<Select
placeholder="Select version"
value={comparePolicyId2 || undefined}
onChange={setComparePolicyId2}
style={{ width: "100%" }}
options={versions.map((v) => ({
label: `v${v.version_number || 1} (${v.version_status || "draft"})`,
value: v.policy_id,
}))}
/>
</Form.Item>
</div>
</Form>
{comparePolicyId1 && comparePolicyId2 && comparePolicyId1 !== comparePolicyId2 && (
<div style={{ maxHeight: "60vh", overflowY: "auto" }}>
<VersionComparison
policyId1={comparePolicyId1}
policyId2={comparePolicyId2}
accessToken={accessToken}
/>
</div>
)}
{comparePolicyId1 && comparePolicyId2 && comparePolicyId1 === comparePolicyId2 && (
<Text type="secondary">Select two different versions to compare.</Text>
)}
</Modal>
</div>
);
};
export default VersionSidebar;

View file

@ -0,0 +1,49 @@
import React from "react";
import { Badge } from "@tremor/react";
export type VersionStatus = "draft" | "published" | "production";
interface VersionStatusBadgeProps {
status: VersionStatus;
size?: "xs" | "sm" | "md" | "lg";
}
const VersionStatusBadge: React.FC<VersionStatusBadgeProps> = ({
status,
size = "sm",
}) => {
const getStatusConfig = (status: VersionStatus) => {
switch (status) {
case "draft":
return {
color: "blue" as const,
label: "Draft",
};
case "published":
return {
color: "yellow" as const,
label: "Published",
};
case "production":
return {
color: "green" as const,
label: "Production",
};
default:
return {
color: "gray" as const,
label: "Unknown",
};
}
};
const config = getStatusConfig(status);
return (
<Badge color={config.color} size={size}>
{config.label}
</Badge>
);
};
export default VersionStatusBadge;