mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
feat(ui/): new policy editor view
This commit is contained in:
parent
9ea62fab04
commit
38406d7efe
17 changed files with 2264 additions and 48 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -100,4 +100,6 @@ STABILIZATION_TODO.md
|
|||
**/test-results
|
||||
**/playwright-report
|
||||
**/*.storageState.json
|
||||
**/coverage
|
||||
**/coverage
|
||||
|
||||
ui/litellm-dashboard/out/__next.*
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
442
ui/litellm-dashboard/src/components/policies/version_sidebar.tsx
Normal file
442
ui/litellm-dashboard/src/components/policies/version_sidebar.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
Loading…
Add table
Reference in a new issue