Merge pull request #34519 from BerriAI/litellm_internal_staging
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CodSpeed Benchmarks / benchmarks (push) Has been cancelled
Helm unit test / unit-test (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
GitHub Actions Security Analysis / zizmor (push) Has been cancelled

chore(ci): promote internal staging to main
This commit is contained in:
yuneng-jiang 2026-07-24 11:00:37 -07:00 • committed by GitHub
commit 0cd588ad10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
110 changed files with 8696 additions and 4594 deletions

View file

@ -1145,6 +1145,7 @@ BEDROCK_CONVERSE_MODELS = [
"anthropic.claude-sonnet-4-5-20250929-v1:0", "anthropic.claude-sonnet-4-5-20250929-v1:0",
"anthropic.claude-fable-5", "anthropic.claude-fable-5",
"anthropic.claude-sonnet-5", "anthropic.claude-sonnet-5",
"anthropic.claude-opus-5",
"anthropic.claude-opus-4-8", "anthropic.claude-opus-4-8",
"anthropic.claude-opus-4-7", "anthropic.claude-opus-4-7",
"anthropic.claude-opus-4-6-v1:0", "anthropic.claude-opus-4-6-v1:0",

View file

@ -1502,6 +1502,222 @@
"supports_parallel_tool_use_config": true, "supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 1024 "prompt_cache_min_tokens": 1024
}, },
"anthropic.claude-opus-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token": 5e-06,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.5e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512
},
"global.anthropic.claude-opus-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token": 5e-06,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.5e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512
},
"us.anthropic.claude-opus-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 6.875e-06,
"cache_creation_input_token_cost_above_1hr": 1.1e-05,
"cache_read_input_token_cost": 5.5e-07,
"input_cost_per_token": 5.5e-06,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.75e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512
},
"eu.anthropic.claude-opus-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 6.875e-06,
"cache_creation_input_token_cost_above_1hr": 1.1e-05,
"cache_read_input_token_cost": 5.5e-07,
"input_cost_per_token": 5.5e-06,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.75e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512
},
"au.anthropic.claude-opus-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 6.875e-06,
"cache_creation_input_token_cost_above_1hr": 1.1e-05,
"cache_read_input_token_cost": 5.5e-07,
"input_cost_per_token": 5.5e-06,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.75e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512
},
"jp.anthropic.claude-opus-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 6.875e-06,
"cache_creation_input_token_cost_above_1hr": 1.1e-05,
"cache_read_input_token_cost": 5.5e-07,
"input_cost_per_token": 5.5e-06,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.75e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512
},
"anthropic.claude-opus-4-8": { "anthropic.claude-opus-4-8": {
"bedrock_converse_supports_strict_tools": false, "bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true, "supports_adaptive_thinking": true,
@ -2756,6 +2972,38 @@
"supports_xhigh_reasoning_effort": true, "supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true "supports_max_reasoning_effort": true
}, },
"azure_ai/claude-opus-5": {
"supports_mid_conversation_system": true,
"supports_adaptive_thinking": true,
"input_cost_per_token": 5e-06,
"output_cost_per_token": 2.5e-05,
"litellm_provider": "azure_ai",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true,
"prompt_cache_min_tokens": 512
},
"azure_ai/claude-opus-4-8": { "azure_ai/claude-opus-4-8": {
"supports_mid_conversation_system": true, "supports_mid_conversation_system": true,
"supports_adaptive_thinking": true, "supports_adaptive_thinking": true,
@ -11846,6 +12094,44 @@
"supports_output_config": true, "supports_output_config": true,
"prompt_cache_min_tokens": 512 "prompt_cache_min_tokens": 512
}, },
"claude-opus-5": {
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token": 5e-06,
"litellm_provider": "anthropic",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.5e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_adaptive_thinking": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_native_structured_output": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true,
"provider_specific_entry": {
"us": 1.1,
"fast": 2.0
},
"supports_output_config": true,
"supports_speed": true,
"prompt_cache_min_tokens": 512
},
"claude-opus-4-8": { "claude-opus-4-8": {
"cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05, "cache_creation_input_token_cost_above_1hr": 1e-05,
@ -36896,6 +37182,70 @@
"supports_xhigh_reasoning_effort": true, "supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true "supports_max_reasoning_effort": true
}, },
"vertex_ai/claude-opus-5": {
"supports_mid_conversation_system": true,
"supports_adaptive_thinking": true,
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token": 5e-06,
"litellm_provider": "vertex_ai-anthropic_models",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.5e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true,
"prompt_cache_min_tokens": 512
},
"vertex_ai/claude-opus-5@default": {
"supports_mid_conversation_system": true,
"supports_adaptive_thinking": true,
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token": 5e-06,
"litellm_provider": "vertex_ai-anthropic_models",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.5e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true,
"prompt_cache_min_tokens": 512
},
"vertex_ai/claude-opus-4-8": { "vertex_ai/claude-opus-4-8": {
"supports_mid_conversation_system": true, "supports_mid_conversation_system": true,
"supports_adaptive_thinking": true, "supports_adaptive_thinking": true,

View file

@ -432,7 +432,7 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase):
Override to store only the Model Armor API response, not the entire data dict. Override to store only the Model Armor API response, not the entire data dict.
This prevents circular references in logging. This prevents circular references in logging.
""" """
metadata = request_data.get("metadata", {}) if isinstance(request_data, dict) else {} metadata = (request_data.get("metadata") or {}) if isinstance(request_data, dict) else {}
guardrail_response = metadata.get("_model_armor_response", {}) guardrail_response = metadata.get("_model_armor_response", {})
# Determine status – default to "success" but prefer the explicit value if present. # Determine status – default to "success" but prefer the explicit value if present.

View file

@ -52,12 +52,13 @@ PROVIDERS: List[Dict] = [
{ {
"id": "anthropic", "id": "anthropic",
"name": "Anthropic", "name": "Anthropic",
"description": "Claude Fable 5, Opus 4.8, Opus 4.7, Opus 4.6, Sonnet 4.6, Haiku 4.5", "description": "Claude Fable 5, Opus 5, Opus 4.8, Opus 4.7, Opus 4.6, Sonnet 5, Sonnet 4.6, Haiku 4.5",
"env_key": "ANTHROPIC_API_KEY", "env_key": "ANTHROPIC_API_KEY",
"key_hint": "sk-ant-...", "key_hint": "sk-ant-...",
"test_model": "claude-haiku-4-5-20251001", "test_model": "claude-haiku-4-5-20251001",
"models": [ "models": [
"claude-fable-5", "claude-fable-5",
"claude-opus-5",
"claude-sonnet-5", "claude-sonnet-5",
"claude-opus-4-8", "claude-opus-4-8",
"claude-opus-4-7", "claude-opus-4-7",

View file

@ -1502,6 +1502,222 @@
"supports_parallel_tool_use_config": true, "supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 1024 "prompt_cache_min_tokens": 1024
}, },
"anthropic.claude-opus-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token": 5e-06,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.5e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512
},
"global.anthropic.claude-opus-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token": 5e-06,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.5e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512
},
"us.anthropic.claude-opus-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 6.875e-06,
"cache_creation_input_token_cost_above_1hr": 1.1e-05,
"cache_read_input_token_cost": 5.5e-07,
"input_cost_per_token": 5.5e-06,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.75e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512
},
"eu.anthropic.claude-opus-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 6.875e-06,
"cache_creation_input_token_cost_above_1hr": 1.1e-05,
"cache_read_input_token_cost": 5.5e-07,
"input_cost_per_token": 5.5e-06,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.75e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512
},
"au.anthropic.claude-opus-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 6.875e-06,
"cache_creation_input_token_cost_above_1hr": 1.1e-05,
"cache_read_input_token_cost": 5.5e-07,
"input_cost_per_token": 5.5e-06,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.75e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512
},
"jp.anthropic.claude-opus-5": {
"bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true,
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 6.875e-06,
"cache_creation_input_token_cost_above_1hr": 1.1e-05,
"cache_read_input_token_cost": 5.5e-07,
"input_cost_per_token": 5.5e-06,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.75e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_native_structured_output": true,
"supports_max_reasoning_effort": true,
"supports_output_config": true,
"supports_parallel_tool_use_config": true,
"prompt_cache_min_tokens": 512
},
"anthropic.claude-opus-4-8": { "anthropic.claude-opus-4-8": {
"bedrock_converse_supports_strict_tools": false, "bedrock_converse_supports_strict_tools": false,
"supports_adaptive_thinking": true, "supports_adaptive_thinking": true,
@ -2756,6 +2972,38 @@
"supports_xhigh_reasoning_effort": true, "supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true "supports_max_reasoning_effort": true
}, },
"azure_ai/claude-opus-5": {
"supports_mid_conversation_system": true,
"supports_adaptive_thinking": true,
"input_cost_per_token": 5e-06,
"output_cost_per_token": 2.5e-05,
"litellm_provider": "azure_ai",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true,
"prompt_cache_min_tokens": 512
},
"azure_ai/claude-opus-4-8": { "azure_ai/claude-opus-4-8": {
"supports_mid_conversation_system": true, "supports_mid_conversation_system": true,
"supports_adaptive_thinking": true, "supports_adaptive_thinking": true,
@ -11846,6 +12094,44 @@
"supports_output_config": true, "supports_output_config": true,
"prompt_cache_min_tokens": 512 "prompt_cache_min_tokens": 512
}, },
"claude-opus-5": {
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token": 5e-06,
"litellm_provider": "anthropic",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.5e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_adaptive_thinking": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_native_structured_output": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true,
"provider_specific_entry": {
"us": 1.1,
"fast": 2.0
},
"supports_output_config": true,
"supports_speed": true,
"prompt_cache_min_tokens": 512
},
"claude-opus-4-8": { "claude-opus-4-8": {
"cache_creation_input_token_cost": 6.25e-06, "cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05, "cache_creation_input_token_cost_above_1hr": 1e-05,
@ -36987,6 +37273,70 @@
"supports_xhigh_reasoning_effort": true, "supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true "supports_max_reasoning_effort": true
}, },
"vertex_ai/claude-opus-5": {
"supports_mid_conversation_system": true,
"supports_adaptive_thinking": true,
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token": 5e-06,
"litellm_provider": "vertex_ai-anthropic_models",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.5e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true,
"prompt_cache_min_tokens": 512
},
"vertex_ai/claude-opus-5@default": {
"supports_mid_conversation_system": true,
"supports_adaptive_thinking": true,
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token": 5e-06,
"litellm_provider": "vertex_ai-anthropic_models",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.5e-05,
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"supports_max_reasoning_effort": true,
"prompt_cache_min_tokens": 512
},
"vertex_ai/claude-opus-4-8": { "vertex_ai/claude-opus-4-8": {
"supports_mid_conversation_system": true, "supports_mid_conversation_system": true,
"supports_adaptive_thinking": true, "supports_adaptive_thinking": true,

View file

@ -85,6 +85,7 @@ class A2ABridgeParams(BaseModel):
custom_llm_provider: str custom_llm_provider: str
model: str model: str
api_key: str | None = None
class AgentRegisterBody(BaseModel): class AgentRegisterBody(BaseModel):

View file

@ -32,7 +32,11 @@ from e2e_config import unique_marker
from e2e_http import Result, UnknownApiError, unwrap from e2e_http import Result, UnknownApiError, unwrap
from lifecycle import ResourceManager from lifecycle import ResourceManager
BRIDGE = A2ABridgeParams(custom_llm_provider="anthropic", model="claude-haiku-4-5") BRIDGE = A2ABridgeParams(
custom_llm_provider="anthropic",
model="claude-haiku-4-5",
api_key="os.environ/ANTHROPIC_API_KEY",
)
MOVEHOME_AGENT_CARD_URL = "https://movehome.org/.well-known/agent.json" MOVEHOME_AGENT_CARD_URL = "https://movehome.org/.well-known/agent.json"
MOVEHOME_ORIGIN = "https://movehome.org" MOVEHOME_ORIGIN = "https://movehome.org"

View file

@ -167,6 +167,13 @@ ANTHROPIC_DIRECT_MODELS: Tuple[ModelEntry, ...] = (
"once the model is available." "once the model is available."
), ),
), ),
ModelEntry(
alias="claude-opus-5",
model="anthropic/claude-opus-5",
mode="adaptive",
required_env=_ANTHROPIC_REQ,
caps=_CAPS_XHIGH_MAX,
),
ModelEntry( ModelEntry(
alias="claude-opus-4-8", alias="claude-opus-4-8",
model="anthropic/claude-opus-4-8", model="anthropic/claude-opus-4-8",

View file

@ -3680,6 +3680,22 @@ async def test_pre_call_hook_skips_chat_traffic_when_configured_for_pre_mcp_call
mock_post.assert_not_called() mock_post.assert_not_called()
def test_process_response_with_none_metadata_does_not_crash():
guardrail = _make_guardrail()
response = {"id": "batch_123", "status": "validating"}
request_data = {"model": "gemini-2.5-flash", "metadata": None}
result = guardrail._process_response(
response=response,
request_data=request_data,
event_type=GuardrailEventHooks.post_call,
)
assert result is response
assert isinstance(request_data["metadata"], dict)
assert "standard_logging_guardrail_information" in request_data["metadata"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_moderation_hook_scans_mcp_tool_call_when_configured_for_during_mcp_call(): async def test_moderation_hook_scans_mcp_tool_call_when_configured_for_during_mcp_call():
"""A guardrail configured with mode `during_mcp_call` must scan MCP tool calls. """A guardrail configured with mode `during_mcp_call` must scan MCP tool calls.

View file

@ -0,0 +1,277 @@
"""
Validate Claude Opus 5 model configuration entries.
Opus 5 carries Opus 4.8's pricing ($5 / $25 per MTok) and the gen-5 adaptive
thinking profile, but differs from 4.8 in two ways that are behavior-bearing in
LiteLLM: the cacheable-prefix minimum drops to 512 tokens, and Bedrock's Opus 5
validator accepts the full effort ladder, so the entries must not carry the
``bedrock_output_config_effort_ceiling`` that silently clamps ``max`` to
``xhigh`` on 4.8. The cost-map entries are also what populate
``litellm.anthropic_models`` at import, which is what lets a bare
``claude-opus-5`` name resolve to the ``anthropic`` provider (and match an
``anthropic/*`` wildcard deployment).
"""
import json
import os
import pytest
import litellm
from litellm.constants import BEDROCK_CONVERSE_MODELS
from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap
REPO_ROOT = os.path.join(os.path.dirname(__file__), "../..")
ALL_OPUS_5_VARIANTS = (
"claude-opus-5",
"anthropic.claude-opus-5",
"global.anthropic.claude-opus-5",
"us.anthropic.claude-opus-5",
"eu.anthropic.claude-opus-5",
"au.anthropic.claude-opus-5",
"jp.anthropic.claude-opus-5",
"vertex_ai/claude-opus-5",
"vertex_ai/claude-opus-5@default",
"azure_ai/claude-opus-5",
)
BEDROCK_OPUS_5_VARIANTS = (
"anthropic.claude-opus-5",
"global.anthropic.claude-opus-5",
"us.anthropic.claude-opus-5",
"eu.anthropic.claude-opus-5",
"au.anthropic.claude-opus-5",
"jp.anthropic.claude-opus-5",
)
def _load_root_cost_map() -> dict:
json_path = os.path.join(REPO_ROOT, "model_prices_and_context_window.json")
with open(json_path) as f:
return json.load(f)
@pytest.fixture
def local_model_cost_map(monkeypatch):
"""Force the bundled backup cost map so assertions don't depend on the
network-fetched ``main`` copy (which lags this branch until merge)."""
original_model_cost = litellm.model_cost
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
litellm.model_cost = litellm.get_model_cost_map(url="")
litellm.get_model_info.cache_clear()
try:
yield
finally:
litellm.model_cost = original_model_cost
litellm.get_model_info.cache_clear()
def test_opus_5_pricing_and_capabilities():
model_data = _load_root_cost_map()
expected_providers = {
"claude-opus-5": "anthropic",
"anthropic.claude-opus-5": "bedrock_converse",
"vertex_ai/claude-opus-5": "vertex_ai-anthropic_models",
"azure_ai/claude-opus-5": "azure_ai",
}
for model_name, provider in expected_providers.items():
assert model_name in model_data, f"Missing model entry: {model_name}"
info = model_data[model_name]
assert info["litellm_provider"] == provider
assert info["mode"] == "chat"
assert info["max_input_tokens"] == 1000000
assert info["max_output_tokens"] == 128000
assert info["max_tokens"] == 128000
# Opus 5 ships at Opus 4.8's rates: $5 / $25 per MTok, with the standard
# 1.25x cache-write, 2x 1-hour cache-write, and 0.1x cache-read multipliers.
assert info["input_cost_per_token"] == 5e-06
assert info["output_cost_per_token"] == 2.5e-05
assert info["cache_creation_input_token_cost"] == 6.25e-06
assert info["cache_creation_input_token_cost_above_1hr"] == 1e-05
assert info["cache_read_input_token_cost"] == 5e-07
# Flat rate across the full 1M window, no long-context premium.
assert "input_cost_per_token_above_200k_tokens" not in info
assert "output_cost_per_token_above_200k_tokens" not in info
# gen-5 adaptive-thinking profile: effort-driven, no sampling params, no
# assistant prefill.
assert info["supports_adaptive_thinking"] is True
assert info["supports_reasoning"] is True
assert info["supports_sampling_params"] is False
assert info["supports_assistant_prefill"] is False
assert info["supports_xhigh_reasoning_effort"] is True
assert info["supports_max_reasoning_effort"] is True
assert info["supports_function_calling"] is True
assert info["supports_prompt_caching"] is True
assert info["supports_tool_choice"] is True
assert info["supports_vision"] is True
def test_opus_5_bedrock_regional_pricing():
"""Global/base endpoints use base pricing; the us./eu./au./jp. regional
cross-region inference profiles carry a 10% premium."""
model_data = _load_root_cost_map()
base_pricing = {
"input_cost_per_token": 5e-06,
"output_cost_per_token": 2.5e-05,
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
}
regional_pricing = {
"input_cost_per_token": 5.5e-06,
"output_cost_per_token": 2.75e-05,
"cache_creation_input_token_cost": 6.875e-06,
"cache_creation_input_token_cost_above_1hr": 1.1e-05,
"cache_read_input_token_cost": 5.5e-07,
}
expected = {
"anthropic.claude-opus-5": base_pricing,
"global.anthropic.claude-opus-5": base_pricing,
"us.anthropic.claude-opus-5": regional_pricing,
"eu.anthropic.claude-opus-5": regional_pricing,
"au.anthropic.claude-opus-5": regional_pricing,
"jp.anthropic.claude-opus-5": regional_pricing,
}
for model_name, pricing in expected.items():
assert model_name in model_data, f"Missing model entry: {model_name}"
info = model_data[model_name]
assert info["litellm_provider"] == "bedrock_converse"
for key, value in pricing.items():
assert info[key] == value, f"{model_name}.{key} = {info[key]}, want {value}"
@pytest.mark.parametrize("model_name", BEDROCK_OPUS_5_VARIANTS)
def test_opus_5_bedrock_entries_declare_no_effort_ceiling(model_name):
"""Bedrock accepts every effort level for Opus 5, so no clamp belongs here.
Opus 4.7/4.8 carry ``bedrock_output_config_effort_ceiling: "xhigh"``, which
is what ``normalize_bedrock_opus_output_config_effort`` reads to rewrite a
caller's effort down. Verified against Bedrock on 2026-07-24 that
``output_config.effort="max"`` returns 200 for the Opus 5 profiles, so the
ceiling is deliberately absent; adding one back would silently downgrade
requests.
This asserts the cost-map entry rather than calling the normalizer because
``_BEDROCK_OUTPUT_CONFIG_EFFORT_ORDER`` currently ranks ``max`` (3) below
``xhigh`` (4), so an ``xhigh`` ceiling never clamps ``max`` and a behavioral
assertion would pass either way. Keeping the entry clean means Opus 5 stays
correct once that ordering is fixed."""
info = _load_root_cost_map()[model_name]
assert "bedrock_output_config_effort_ceiling" not in info
@pytest.mark.parametrize("model_name", BEDROCK_OPUS_5_VARIANTS)
def test_opus_5_bedrock_rejects_strict_tools(model_name, local_model_cost_map):
"""Bedrock Converse routes Opus through a validator that rejects
``toolSpec.strict`` (``tools.0.custom.strict: Extra inputs are not
permitted``), same as Opus 4.7/4.8; verified against Bedrock on 2026-07-24.
Without the flag LiteLLM forwards ``strict`` and every tool call 400s."""
from litellm.llms.bedrock.common_utils import bedrock_converse_supports_strict_tools
assert bedrock_converse_supports_strict_tools(model_name) is False
def test_opus_5_prompt_cache_minimum_is_512(local_model_cost_map):
"""Opus 5 halves the cacheable-prefix minimum (Opus 4.8 is 1024).
The router's prompt-caching deployment check reads this value, so a stale
1024 would route prompts of 512-1023 tokens away from a warm Opus 5
deployment even though they cache fine."""
from litellm.utils import get_prompt_cache_min_tokens
assert get_prompt_cache_min_tokens(model="claude-opus-5") == 512
assert get_prompt_cache_min_tokens(model="us.anthropic.claude-opus-5") == 512
def test_opus_5_supports_fast_mode(local_model_cost_map):
"""Fast mode is Opus 5 on the first-party API at $10 / $50 per MTok, i.e. 2x
base. ``supports_speed`` gates whether ``speed="fast"`` is forwarded at all,
and ``provider_specific_entry.fast`` is what prices the response."""
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
from litellm.llms.anthropic.cost_calculation import (
cost_per_token as anthropic_cost_per_token,
)
from litellm.types.utils import Usage
assert (
AnthropicConfig._model_supports_speed_param("claude-opus-5", "anthropic") is True
)
usage = Usage(prompt_tokens=1000, completion_tokens=500, total_tokens=1500)
usage.speed = "fast"
prompt_cost, completion_cost = anthropic_cost_per_token(
model="claude-opus-5", usage=usage
)
assert prompt_cost == pytest.approx(1000 * 5e-06 * 2.0)
assert completion_cost == pytest.approx(500 * 2.5e-05 * 2.0)
def test_opus_5_present_in_bundled_backup():
"""The bundled backup is the runtime fallback (and what tests load with
``LITELLM_LOCAL_MODEL_COST_MAP=True``); it must carry the same entries as the
root cost map, otherwise the model resolves on one path but not the other."""
backup = GetModelCostMap.load_local_model_cost_map()
for model_name in ALL_OPUS_5_VARIANTS:
assert model_name in backup, f"Missing from backup cost map: {model_name}"
def test_opus_5_registered_for_bedrock_converse():
assert "anthropic.claude-opus-5" in BEDROCK_CONVERSE_MODELS
def test_opus_5_provider_resolves_via_model_info(local_model_cost_map):
"""Regression: ``claude-opus-5`` must resolve to provider ``anthropic``.
Without the cost-map entry the model is unknown to LiteLLM, so it cannot be
tied to the ``anthropic`` provider and an ``anthropic/*`` wildcard deployment
would not match it."""
info = litellm.get_model_info(model="claude-opus-5")
assert info["litellm_provider"] == "anthropic"
assert info["max_input_tokens"] == 1000000
assert info["max_output_tokens"] == 128000
@pytest.mark.parametrize(
"cost_map",
[_load_root_cost_map(), GetModelCostMap.load_local_model_cost_map()],
ids=["root", "bundled_backup"],
)
def test_opus_5_all_variants_carry_adaptive_thinking_flag(cost_map):
"""Every Opus 5 entry must advertise ``supports_adaptive_thinking``.
Adaptive-thinking detection is cost-map driven, so a single variant missing
the flag silently sends the legacy ``thinking.type='enabled'`` shape, which
Opus 5 rejects with a 400."""
variants = [k for k in cost_map if "claude-opus-5" in k]
assert variants, "no claude-opus-5 entries found in cost map"
missing = [
k for k in variants if cost_map[k].get("supports_adaptive_thinking") is not True
]
assert not missing, f"missing supports_adaptive_thinking: {missing}"
@pytest.mark.parametrize(
"cost_map",
[_load_root_cost_map(), GetModelCostMap.load_local_model_cost_map()],
ids=["root", "bundled_backup"],
)
def test_opus_5_all_variants_carry_512_token_cache_minimum(cost_map):
variants = [k for k in cost_map if "claude-opus-5" in k]
assert variants, "no claude-opus-5 entries found in cost map"
wrong = {
k: cost_map[k].get("prompt_cache_min_tokens")
for k in variants
if cost_map[k].get("prompt_cache_min_tokens") != 512
}
assert not wrong, f"prompt_cache_min_tokens must be 512: {wrong}"

View file

@ -4,11 +4,6 @@
"count": 1 "count": 1
} }
}, },
"src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx": { "src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx": {
"no-restricted-imports": { "no-restricted-imports": {
"count": 2 "count": 2
@ -24,11 +19,6 @@
"count": 1 "count": 1
} }
}, },
"src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx": { "src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx": {
"no-restricted-imports": { "no-restricted-imports": {
"count": 2 "count": 2
@ -144,9 +134,6 @@
"src/app/(dashboard)/budgets/_components/budget_panel.tsx": { "src/app/(dashboard)/budgets/_components/budget_panel.tsx": {
"local/filename-pascal-case": { "local/filename-pascal-case": {
"count": 1 "count": 1
},
"no-restricted-imports": {
"count": 1
} }
}, },
"src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx": { "src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx": {
@ -177,9 +164,6 @@
"src/app/(dashboard)/caching/_components/cache_health.tsx": { "src/app/(dashboard)/caching/_components/cache_health.tsx": {
"local/filename-pascal-case": { "local/filename-pascal-case": {
"count": 1 "count": 1
},
"no-restricted-imports": {
"count": 1
} }
}, },
"src/app/(dashboard)/caching/_components/cache_settings/CacheFormField.tsx": { "src/app/(dashboard)/caching/_components/cache_settings/CacheFormField.tsx": {
@ -187,11 +171,6 @@
"count": 1 "count": 1
} }
}, },
"src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsFields.ts": { "src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsFields.ts": {
"no-restricted-imports": { "no-restricted-imports": {
"count": 1 "count": 1
@ -213,11 +192,6 @@
"count": 1 "count": 1
} }
}, },
"src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/caching/_components/coordination_redis_settings/coordinationRedisFields.ts": { "src/app/(dashboard)/caching/_components/coordination_redis_settings/coordinationRedisFields.ts": {
"no-restricted-imports": { "no-restricted-imports": {
"count": 1 "count": 1
@ -838,15 +812,7 @@
"count": 1 "count": 1
} }
}, },
"src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx": { "src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/immutability": { "react-hooks/immutability": {
"count": 2 "count": 2
} }
@ -861,11 +827,6 @@
"count": 1 "count": 1
} }
}, },
"src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx": { "src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx": {
"react-hooks/set-state-in-effect": { "react-hooks/set-state-in-effect": {
"count": 1 "count": 1
@ -904,9 +865,6 @@
} }
}, },
"src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx": { "src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": { "react-hooks/set-state-in-effect": {
"count": 1 "count": 1
} }
@ -947,11 +905,6 @@
"count": 1 "count": 1
} }
}, },
"src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx": { "src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx": {
"no-nested-ternary": { "no-nested-ternary": {
"count": 2 "count": 2
@ -999,9 +952,6 @@
}, },
"no-nested-ternary": { "no-nested-ternary": {
"count": 3 "count": 3
},
"no-restricted-imports": {
"count": 2
} }
}, },
"src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx": { "src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx": {
@ -1011,9 +961,6 @@
"local/no-complex-jsx-arrow": { "local/no-complex-jsx-arrow": {
"count": 1 "count": 1
}, },
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": { "react-hooks/set-state-in-effect": {
"count": 2 "count": 2
} }
@ -1021,17 +968,11 @@
"src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx": { "src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx": {
"local/filename-pascal-case": { "local/filename-pascal-case": {
"count": 1 "count": 1
},
"no-restricted-imports": {
"count": 2
} }
}, },
"src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx": { "src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx": {
"local/filename-pascal-case": { "local/filename-pascal-case": {
"count": 1 "count": 1
},
"no-restricted-imports": {
"count": 1
} }
}, },
"src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx": { "src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx": {
@ -1057,9 +998,6 @@
"src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx": { "src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx": {
"local/filename-pascal-case": { "local/filename-pascal-case": {
"count": 1 "count": 1
},
"no-restricted-imports": {
"count": 2
} }
}, },
"src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx": { "src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx": {
@ -1072,9 +1010,6 @@
"no-nested-ternary": { "no-nested-ternary": {
"count": 1 "count": 1
}, },
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": { "react-hooks/set-state-in-effect": {
"count": 2 "count": 2
} }
@ -1082,9 +1017,6 @@
"src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx": { "src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx": {
"local/filename-pascal-case": { "local/filename-pascal-case": {
"count": 1 "count": 1
},
"no-restricted-imports": {
"count": 2
} }
}, },
"src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx": { "src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx": {
@ -1097,9 +1029,6 @@
"no-nested-ternary": { "no-nested-ternary": {
"count": 1 "count": 1
}, },
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": { "react-hooks/set-state-in-effect": {
"count": 2 "count": 2
} }
@ -1109,21 +1038,11 @@
"count": 1 "count": 1
} }
}, },
"src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/memory/_components/MemoryEditModal.tsx": { "src/app/(dashboard)/memory/_components/MemoryEditModal.tsx": {
"no-restricted-imports": { "no-restricted-imports": {
"count": 1 "count": 1
} }
}, },
"src/app/(dashboard)/memory/_components/MemoryView.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.test.tsx": { "src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.test.tsx": {
"react/display-name": { "react/display-name": {
"count": 1 "count": 1
@ -1469,9 +1388,6 @@
"no-nested-ternary": { "no-nested-ternary": {
"count": 10 "count": 10
}, },
"no-restricted-imports": {
"count": 2
},
"react-hooks/immutability": { "react-hooks/immutability": {
"count": 1 "count": 1
} }
@ -1483,9 +1399,6 @@
"no-nested-ternary": { "no-nested-ternary": {
"count": 1 "count": 1
}, },
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": { "react-hooks/set-state-in-effect": {
"count": 1 "count": 1
} }
@ -1509,9 +1422,6 @@
"src/app/(dashboard)/policies/_components/impact_preview_alert.tsx": { "src/app/(dashboard)/policies/_components/impact_preview_alert.tsx": {
"local/filename-pascal-case": { "local/filename-pascal-case": {
"count": 1 "count": 1
},
"no-restricted-imports": {
"count": 1
} }
}, },
"src/app/(dashboard)/policies/_components/index.test.tsx": { "src/app/(dashboard)/policies/_components/index.test.tsx": {
@ -1526,9 +1436,6 @@
"local/no-complex-jsx-arrow": { "local/no-complex-jsx-arrow": {
"count": 1 "count": 1
}, },
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": { "react-hooks/set-state-in-effect": {
"count": 1 "count": 1
} }
@ -1543,9 +1450,6 @@
"no-nested-ternary": { "no-nested-ternary": {
"count": 1 "count": 1
}, },
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": { "react-hooks/set-state-in-effect": {
"count": 2 "count": 2
} }
@ -1554,9 +1458,6 @@
"local/filename-pascal-case": { "local/filename-pascal-case": {
"count": 1 "count": 1
}, },
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": { "react-hooks/set-state-in-effect": {
"count": 1 "count": 1
} }
@ -1564,9 +1465,6 @@
"src/app/(dashboard)/policies/_components/policy_templates.tsx": { "src/app/(dashboard)/policies/_components/policy_templates.tsx": {
"local/filename-pascal-case": { "local/filename-pascal-case": {
"count": 1 "count": 1
},
"no-restricted-imports": {
"count": 1
} }
}, },
"src/app/(dashboard)/policies/_components/policy_test_panel.tsx": { "src/app/(dashboard)/policies/_components/policy_test_panel.tsx": {
@ -1584,9 +1482,6 @@
"local/filename-pascal-case": { "local/filename-pascal-case": {
"count": 1 "count": 1
}, },
"no-restricted-imports": {
"count": 2
},
"react-hooks/immutability": { "react-hooks/immutability": {
"count": 1 "count": 1
}, },
@ -1840,9 +1735,6 @@
} }
}, },
"src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx": { "src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx": {
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": { "react-hooks/set-state-in-effect": {
"count": 1 "count": 1
} }
@ -1864,9 +1756,6 @@
"local/filename-pascal-case": { "local/filename-pascal-case": {
"count": 1 "count": 1
}, },
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": { "react-hooks/set-state-in-effect": {
"count": 1 "count": 1
} }
@ -1888,9 +1777,6 @@
} }
}, },
"src/app/(dashboard)/ui-theme/UIThemeSettings.tsx": { "src/app/(dashboard)/ui-theme/UIThemeSettings.tsx": {
"no-restricted-imports": {
"count": 1
},
"no-restricted-syntax": { "no-restricted-syntax": {
"count": 3 "count": 3
}, },
@ -2054,11 +1940,6 @@
"count": 1 "count": 1
} }
}, },
"src/app/(dashboard)/vector-stores/_components/TestVectorStoreTab.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx": { "src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx": {
"no-nested-ternary": { "no-nested-ternary": {
"count": 2 "count": 2
@ -2070,18 +1951,10 @@
"count": 1 "count": 1
} }
}, },
"src/app/(dashboard)/vector-stores/_components/VectorStoreTester.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/vector-stores/_components/index.tsx": { "src/app/(dashboard)/vector-stores/_components/index.tsx": {
"local/filename-pascal-case": { "local/filename-pascal-case": {
"count": 1 "count": 1
}, },
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": { "react-hooks/set-state-in-effect": {
"count": 1 "count": 1
} }
@ -2218,26 +2091,11 @@
"count": 1 "count": 1
} }
}, },
"src/components/CloudZeroCostTracking/CloudZeroCostTracking.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx": { "src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx": {
"no-restricted-imports": { "no-restricted-imports": {
"count": 1 "count": 1
} }
}, },
"src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx": { "src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx": {
"no-restricted-imports": { "no-restricted-imports": {
"count": 1 "count": 1
@ -2435,11 +2293,6 @@
"count": 1 "count": 1
} }
}, },
"src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/Settings/AdminSettings/PluginSettings/PluginSettings.tsx": { "src/components/Settings/AdminSettings/PluginSettings/PluginSettings.tsx": {
"no-restricted-imports": { "no-restricted-imports": {
"count": 1 "count": 1
@ -2605,20 +2458,7 @@
} }
}, },
"src/components/ToolDetail.tsx": { "src/components/ToolDetail.tsx": {
"no-restricted-imports": {
"count": 1
},
"unused-imports/no-unused-imports": { "unused-imports/no-unused-imports": {
"count": 2
}
},
"src/components/ToolPolicies/PolicySelect.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/ToolPolicies/ToolPoliciesTableColumns.tsx": {
"no-restricted-imports": {
"count": 1 "count": 1
} }
}, },
@ -3017,23 +2857,10 @@
} }
}, },
"src/components/common_components/Filters/FilterInput.tsx": { "src/components/common_components/Filters/FilterInput.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": { "react-hooks/set-state-in-effect": {
"count": 1 "count": 1
} }
}, },
"src/components/common_components/Filters/FiltersButton.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/Filters/ResetFiltersButton.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/IconActionButton/BaseActionButton.tsx": { "src/components/common_components/IconActionButton/BaseActionButton.tsx": {
"no-restricted-imports": { "no-restricted-imports": {
"count": 1 "count": 1
@ -3240,9 +3067,6 @@
"local/filename-pascal-case": { "local/filename-pascal-case": {
"count": 1 "count": 1
}, },
"no-restricted-imports": {
"count": 2
},
"react-hooks/immutability": { "react-hooks/immutability": {
"count": 1 "count": 1
} }
@ -3251,9 +3075,6 @@
"local/filename-pascal-case": { "local/filename-pascal-case": {
"count": 1 "count": 1
}, },
"no-restricted-imports": {
"count": 2
},
"prefer-const": { "prefer-const": {
"count": 1 "count": 1
} }
@ -3595,7 +3416,7 @@
"count": 1 "count": 1
}, },
"no-restricted-imports": { "no-restricted-imports": {
"count": 3 "count": 1
} }
}, },
"src/components/page_utils.test.ts": { "src/components/page_utils.test.ts": {
@ -4116,6 +3937,11 @@
"count": 1 "count": 1
} }
}, },
"src/components/ui/radio-group.tsx": {
"local/filename-pascal-case": {
"count": 1
}
},
"src/components/ui/scroll-area.tsx": { "src/components/ui/scroll-area.tsx": {
"local/filename-pascal-case": { "local/filename-pascal-case": {
"count": 1 "count": 1
@ -4304,7 +4130,7 @@
}, },
"src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx": { "src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx": {
"no-nested-ternary": { "no-nested-ternary": {
"count": 3 "count": 2
}, },
"no-restricted-imports": { "no-restricted-imports": {
"count": 1 "count": 1

View file

@ -1,68 +1,63 @@
import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails"; import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails";
import {
Button,
Card,
Col,
Descriptions,
Empty,
Flex,
Layout,
List,
Row,
Spin,
Tabs,
Tag,
theme,
Typography,
} from "antd";
import { ArrowLeftIcon, BotIcon, EditIcon, KeyIcon, LayersIcon, ServerIcon, UsersIcon } from "lucide-react"; import { ArrowLeftIcon, BotIcon, EditIcon, KeyIcon, LayersIcon, ServerIcon, UsersIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag"; import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
import CopyButton from "@/components/shared/CopyButton";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { AccessGroupEditModal } from "./AccessGroupsModal/AccessGroupEditModal"; import { AccessGroupEditModal } from "./AccessGroupsModal/AccessGroupEditModal";
const { Title, Text } = Typography;
const { Content } = Layout;
interface AccessGroupDetailProps { interface AccessGroupDetailProps {
accessGroupId: string; accessGroupId: string;
onBack: () => void; onBack: () => void;
} }
const MAX_PREVIEW = 5;
function ResourceList({ ids, emptyMessage }: { ids: string[]; emptyMessage: string }) {
if (ids.length === 0) {
return <p className="py-8 text-center text-sm text-muted-foreground">{emptyMessage}</p>;
}
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{ids.map((id) => (
<Card key={id} size="sm">
<CardContent>
<code className="font-mono text-xs break-all text-foreground">{id}</code>
</CardContent>
</Card>
))}
</div>
);
}
export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailProps) { export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailProps) {
const { data: accessGroup, isLoading } = useAccessGroupDetails(accessGroupId); const { data: accessGroup, isLoading } = useAccessGroupDetails(accessGroupId);
const { token } = theme.useToken();
const [isEditModalVisible, setIsEditModalVisible] = useState(false); const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const [showAllKeys, setShowAllKeys] = useState(false); const [showAllKeys, setShowAllKeys] = useState(false);
const [showAllTeams, setShowAllTeams] = useState(false); const [showAllTeams, setShowAllTeams] = useState(false);
const MAX_PREVIEW = 5;
if (isLoading) { if (isLoading) {
return ( return (
<Content <div className="p-6 px-12">
style={{ <div className="flex min-h-[300px] items-center justify-center">
padding: token.paddingLG, <UiLoadingSpinner className="size-8 text-primary" />
paddingInline: token.paddingLG * 2, </div>
}} </div>
>
<Flex justify="center" align="center" style={{ minHeight: 300 }}>
<Spin size="large" />
</Flex>
</Content>
); );
} }
if (!accessGroup) { if (!accessGroup) {
return ( return (
<Content <div className="p-6 px-12">
style={{ <Button variant="ghost" size="icon" aria-label="Back" onClick={onBack} className="mb-4">
padding: token.paddingLG, <ArrowLeftIcon className="size-4" />
paddingInline: token.paddingLG * 2, </Button>
}} <p className="py-8 text-center text-sm text-muted-foreground">Access group not found</p>
> </div>
<Button icon={<ArrowLeftIcon size={16} />} onClick={onBack} type="text" style={{ marginBottom: 16 }} />
<Empty description="Access group not found" />
</Content>
); );
} }
@ -75,224 +70,159 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
const displayedKeys = showAllKeys ? keyIds : keyIds.slice(0, MAX_PREVIEW); const displayedKeys = showAllKeys ? keyIds : keyIds.slice(0, MAX_PREVIEW);
const displayedTeams = showAllTeams ? teamIds : teamIds.slice(0, MAX_PREVIEW); const displayedTeams = showAllTeams ? teamIds : teamIds.slice(0, MAX_PREVIEW);
const handleEdit = () => {
setIsEditModalVisible(true);
};
const tabItems = [
{
key: "models",
label: (
<Flex align="center" gap={8}>
<LayersIcon size={16} />
Models
<Tag style={{ marginInlineEnd: 0 }}>{modelIds?.length}</Tag>
</Flex>
),
children:
modelIds?.length > 0 ? (
<List
grid={{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 4 }}
dataSource={modelIds}
renderItem={(id) => (
<List.Item>
<Card size="small">
<Text code>{id}</Text>
</Card>
</List.Item>
)}
/>
) : (
<Empty description="No models assigned to this group" />
),
},
{
key: "mcp",
label: (
<Flex align="center" gap={8}>
<ServerIcon size={16} />
MCP Servers
<Tag>{mcpServerIds?.length}</Tag>
</Flex>
),
children:
mcpServerIds?.length > 0 ? (
<List
grid={{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 4 }}
dataSource={mcpServerIds}
renderItem={(id) => (
<List.Item>
<Card size="small">
<Text code>{id}</Text>
</Card>
</List.Item>
)}
/>
) : (
<Empty description="No MCP servers assigned to this group" />
),
},
{
key: "agents",
label: (
<Flex align="center" gap={8}>
<BotIcon size={16} />
Agents
<Tag>{agentIds?.length}</Tag>
</Flex>
),
children:
agentIds?.length > 0 ? (
<List
grid={{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 4 }}
dataSource={agentIds}
renderItem={(id) => (
<List.Item>
<Card size="small">
<Text code>{id}</Text>
</Card>
</List.Item>
)}
/>
) : (
<Empty description="No agents assigned to this group" />
),
},
];
return ( return (
<Content style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}> <div className="p-6 px-12">
{/* Header */} <div className="mb-6 flex items-center justify-between">
<div <div className="flex items-center gap-4">
style={{ <Button variant="ghost" size="icon" aria-label="Back" onClick={onBack}>
display: "flex", <ArrowLeftIcon className="size-4" />
justifyContent: "space-between", </Button>
alignItems: "center",
marginBottom: 24,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<Button icon={<ArrowLeftIcon size={16} />} onClick={onBack} type="text" />
<div> <div>
<Title level={2} style={{ margin: 0 }}> <h1 className="text-xl font-semibold tracking-tight text-foreground">{accessGroup.access_group_name}</h1>
{accessGroup.access_group_name} <div className="flex items-center gap-1 text-sm text-muted-foreground">
</Title> <span>ID: {accessGroup.access_group_id}</span>
<Text type="secondary"> <CopyButton value={accessGroup.access_group_id} label="Copy access group ID" />
ID: <Text copyable>{accessGroup.access_group_id}</Text>
</Text>
</div> </div>
</div> </div>
<Button type="primary" icon={<EditIcon size={16} />} onClick={handleEdit}> </div>
<Button onClick={() => setIsEditModalVisible(true)}>
<EditIcon className="size-4" />
Edit Access Group Edit Access Group
</Button> </Button>
</div> </div>
{/* Group Details */} <Card className="mb-6">
<Row style={{ marginBottom: 24 }}> <CardHeader>
<Card> <CardTitle>Group Details</CardTitle>
<Descriptions title="Group Details" column={1}> </CardHeader>
<Descriptions.Item label="Description">{accessGroup.description || "—"}</Descriptions.Item> <CardContent>
<Descriptions.Item label="Created"> <dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2 text-sm">
<dt className="text-muted-foreground">Description</dt>
<dd className="text-foreground">{accessGroup.description || "—"}</dd>
<dt className="text-muted-foreground">Created</dt>
<dd className="flex items-center gap-1 text-foreground">
{new Date(accessGroup.created_at).toLocaleString()} {new Date(accessGroup.created_at).toLocaleString()}
{accessGroup.created_by && ( {accessGroup.created_by && (
<Text> <>
&nbsp;{"by"}&nbsp; <span>by</span>
<DefaultProxyAdminTag userId={accessGroup.created_by} /> <DefaultProxyAdminTag userId={accessGroup.created_by} />
</Text> </>
)} )}
</Descriptions.Item> </dd>
<Descriptions.Item label="Last Updated"> <dt className="text-muted-foreground">Last Updated</dt>
<dd className="flex items-center gap-1 text-foreground">
{new Date(accessGroup.updated_at).toLocaleString()} {new Date(accessGroup.updated_at).toLocaleString()}
{accessGroup.updated_by && ( {accessGroup.updated_by && (
<Text> <>
&nbsp;{"by"}&nbsp; <span>by</span>
<DefaultProxyAdminTag userId={accessGroup.updated_by} /> <DefaultProxyAdminTag userId={accessGroup.updated_by} />
</Text> </>
)} )}
</Descriptions.Item> </dd>
</Descriptions> </dl>
</CardContent>
</Card> </Card>
</Row>
{/* Attached Keys & Teams */} <div className="mb-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} lg={12}>
<Card
title={
<Flex align="center" gap={8}>
<KeyIcon size={16} />
Attached Keys
<Tag>{keyIds?.length}</Tag>
</Flex>
}
extra={
keyIds?.length > MAX_PREVIEW ? (
<Button type="link" onClick={() => setShowAllKeys(!showAllKeys)}>
{showAllKeys ? "Show Less" : `View All (${keyIds?.length})`}
</Button>
) : null
}
>
{keyIds?.length > 0 ? (
<Flex wrap="wrap" gap={8}>
{displayedKeys.map((id) => (
<Tag key={id}>
<Text code style={{ fontSize: 12 }}>
{id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-6)}` : id}
</Text>
</Tag>
))}
</Flex>
) : (
<Empty description="No keys attached" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
title={
<Flex align="center" gap={8}>
<UsersIcon size={16} />
Attached Teams
<Tag>{teamIds?.length}</Tag>
</Flex>
}
extra={
teamIds?.length > MAX_PREVIEW ? (
<Button type="link" onClick={() => setShowAllTeams(!showAllTeams)}>
{showAllTeams ? "Show Less" : `View All (${teamIds?.length})`}
</Button>
) : null
}
>
{teamIds?.length > 0 ? (
<Flex wrap="wrap" gap={8}>
{displayedTeams.map((id) => (
<Tag key={id}>
<Text code style={{ fontSize: 12 }}>
{id}
</Text>
</Tag>
))}
</Flex>
) : (
<Empty description="No teams attached" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
</Col>
</Row>
{/* Resources Tabs */}
<Card> <Card>
<Tabs defaultActiveKey="models" items={tabItems} /> <CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyIcon className="size-4" />
Attached Keys
<Badge variant="secondary">{keyIds.length}</Badge>
</CardTitle>
{keyIds.length > MAX_PREVIEW && (
<CardAction>
<Button variant="link" size="sm" onClick={() => setShowAllKeys(!showAllKeys)}>
{showAllKeys ? "Show Less" : `View All (${keyIds.length})`}
</Button>
</CardAction>
)}
</CardHeader>
<CardContent>
{keyIds.length > 0 ? (
<div className="flex flex-wrap gap-2">
{displayedKeys.map((id) => (
<Badge key={id} variant="secondary" className="font-mono">
{id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-6)}` : id}
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No keys attached</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UsersIcon className="size-4" />
Attached Teams
<Badge variant="secondary">{teamIds.length}</Badge>
</CardTitle>
{teamIds.length > MAX_PREVIEW && (
<CardAction>
<Button variant="link" size="sm" onClick={() => setShowAllTeams(!showAllTeams)}>
{showAllTeams ? "Show Less" : `View All (${teamIds.length})`}
</Button>
</CardAction>
)}
</CardHeader>
<CardContent>
{teamIds.length > 0 ? (
<div className="flex flex-wrap gap-2">
{displayedTeams.map((id) => (
<Badge key={id} variant="secondary" className="font-mono">
{id}
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No teams attached</p>
)}
</CardContent>
</Card>
</div>
<Card>
<CardContent>
<Tabs defaultValue="models">
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="models" className="flex-none gap-2 rounded-none px-4 py-2">
<LayersIcon className="size-4" />
Models
<Badge variant="secondary">{modelIds.length}</Badge>
</TabsTrigger>
<TabsTrigger value="mcp" className="flex-none gap-2 rounded-none px-4 py-2">
<ServerIcon className="size-4" />
MCP Servers
<Badge variant="secondary">{mcpServerIds.length}</Badge>
</TabsTrigger>
<TabsTrigger value="agents" className="flex-none gap-2 rounded-none px-4 py-2">
<BotIcon className="size-4" />
Agents
<Badge variant="secondary">{agentIds.length}</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="models" className="pt-4">
<ResourceList ids={modelIds} emptyMessage="No models assigned to this group" />
</TabsContent>
<TabsContent value="mcp" className="pt-4">
<ResourceList ids={mcpServerIds} emptyMessage="No MCP servers assigned to this group" />
</TabsContent>
<TabsContent value="agents" className="pt-4">
<ResourceList ids={agentIds} emptyMessage="No agents assigned to this group" />
</TabsContent>
</Tabs>
</CardContent>
</Card> </Card>
{/* Edit Modal */}
<AccessGroupEditModal <AccessGroupEditModal
visible={isEditModalVisible} visible={isEditModalVisible}
accessGroup={accessGroup} accessGroup={accessGroup}
onCancel={() => setIsEditModalVisible(false)} onCancel={() => setIsEditModalVisible(false)}
/> />
</Content> </div>
); );
} }

View file

@ -1,10 +1,11 @@
import { AccessGroupResponse, useAccessGroups } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups"; import { AccessGroupResponse, useAccessGroups } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
import { useDeleteAccessGroup } from "@/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup"; import { useDeleteAccessGroup } from "@/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup";
import { PlusOutlined } from "@ant-design/icons"; import { Plus, SearchIcon, X } from "lucide-react";
import { Button, Flex, Input, Layout, Space, theme, Typography } from "antd";
import { SearchIcon } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import { PageHeader } from "@/components/shared/PageHeader";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { AccessGroupDetail } from "./AccessGroupsDetailsPage"; import { AccessGroupDetail } from "./AccessGroupsDetailsPage";
import { AccessGroupCreateModal } from "./AccessGroupsModal/AccessGroupCreateModal"; import { AccessGroupCreateModal } from "./AccessGroupsModal/AccessGroupCreateModal";
import { AccessGroupsTable } from "./AccessGroupsTable"; import { AccessGroupsTable } from "./AccessGroupsTable";
@ -12,9 +13,6 @@ import { AccessGroup } from "./types";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { isProxyAdminRole } from "@/utils/roles"; import { isProxyAdminRole } from "@/utils/roles";
const { Title, Text } = Typography;
const { Content } = Layout;
function mapResponseToAccessGroup(r: AccessGroupResponse): AccessGroup { function mapResponseToAccessGroup(r: AccessGroupResponse): AccessGroup {
return { return {
id: r.access_group_id, id: r.access_group_id,
@ -33,7 +31,6 @@ function mapResponseToAccessGroup(r: AccessGroupResponse): AccessGroup {
} }
export function AccessGroupsPage() { export function AccessGroupsPage() {
const { token } = theme.useToken();
const { userRole } = useAuthorized(); const { userRole } = useAuthorized();
// Admin Viewer follows the read-parity rule: see access groups, no writes. // Admin Viewer follows the read-parity rule: see access groups, no writes.
const canModify = isProxyAdminRole(userRole ?? ""); const canModify = isProxyAdminRole(userRole ?? "");
@ -62,31 +59,41 @@ export function AccessGroupsPage() {
} }
return ( return (
<Content style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}> <div className="p-6 px-12">
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}> <div className="mb-4">
<Space direction="vertical" size={0}> <PageHeader
<Title level={2} style={{ margin: 0 }}> title="Access Groups"
Access Groups subtitle="Manage resource permissions for your organization"
</Title> actions={
<Text type="secondary">Manage resource permissions for your organization</Text> canModify ? (
</Space> <Button onClick={() => setIsCreateModalVisible(true)}>
{canModify && ( <Plus className="size-4" />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setIsCreateModalVisible(true)}>
Create Access Group Create Access Group
</Button> </Button>
)} ) : undefined
</Flex> }
/>
</div>
<Flex align="center" style={{ marginBottom: 12 }}> <div className="mb-3 flex items-center">
<Input <InputGroup className="max-w-[400px]">
prefix={<SearchIcon size={16} />} <InputGroupAddon>
<SearchIcon className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search groups by name, ID, or description..." placeholder="Search groups by name, ID, or description..."
style={{ maxWidth: 400 }}
value={searchText} value={searchText}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
allowClear
/> />
</Flex> {searchText && (
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={() => setSearchText("")}>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
</div>
<AccessGroupsTable <AccessGroupsTable
groups={filteredGroups} groups={filteredGroups}
@ -120,6 +127,6 @@ export function AccessGroupsPage() {
}} }}
confirmLoading={deleteMutation.isPending} confirmLoading={deleteMutation.isPending}
/> />
</Content> </div>
); );
} }

View file

@ -3,9 +3,10 @@
* *
*/ */
import { Button, Tab, TabGroup, TabList, TabPanel, TabPanels, Text } from "@tremor/react";
import React, { useState } from "react"; import React, { useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import NotificationsManager from "@/components/molecules/notifications_manager"; import NotificationsManager from "@/components/molecules/notifications_manager";
import { useBudgets, useDeleteBudget, budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; import { useBudgets, useDeleteBudget, budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
@ -73,17 +74,20 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
return ( return (
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2"> <div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
{canModify && ( {canModify && (
<Button size="sm" variant="primary" className="mb-2" onClick={() => setIsCreateModelVisible(true)}> <Button size="sm" className="mb-2" onClick={() => setIsCreateModelVisible(true)}>
+ Create Budget + Create Budget
</Button> </Button>
)} )}
<TabGroup> <Tabs defaultValue="budgets">
<TabList> <TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
<Tab>Budgets</Tab> <TabsTrigger value="budgets" className="flex-none rounded-none px-4 py-2">
<Tab>Examples</Tab> Budgets
</TabList> </TabsTrigger>
<TabPanels> <TabsTrigger value="examples" className="flex-none rounded-none px-4 py-2">
<TabPanel> Examples
</TabsTrigger>
</TabsList>
<TabsContent value="budgets">
<div className="mt-6"> <div className="mt-6">
<BudgetModal isModalVisible={isCreateModelVisible} setIsModalVisible={setIsCreateModelVisible} /> <BudgetModal isModalVisible={isCreateModelVisible} setIsModalVisible={setIsCreateModelVisible} />
{selectedBudget && ( {selectedBudget && (
@ -93,7 +97,7 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
existingBudget={selectedBudget} existingBudget={selectedBudget}
/> />
)} )}
<Text className="mb-4">Create a budget to assign to customers.</Text> <p className="mb-4 text-sm text-muted-foreground">Create a budget to assign to customers.</p>
<BudgetTable <BudgetTable
budgets={budgetList} budgets={budgetList}
isLoading={isLoading} isLoading={isLoading}
@ -117,32 +121,35 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
confirmLoading={deleteBudget.isPending} confirmLoading={deleteBudget.isPending}
/> />
</div> </div>
</TabPanel> </TabsContent>
<TabPanel> <TabsContent value="examples">
<div className="mt-6"> <div className="mt-6">
<Text className="text-base">How to use budget id</Text> <p className="text-base text-muted-foreground">How to use budget id</p>
<TabGroup> <Tabs defaultValue="assign-budget">
<TabList> <TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
<Tab>Assign Budget to Customer</Tab> <TabsTrigger value="assign-budget" className="flex-none rounded-none px-4 py-2">
<Tab>Test it (Curl)</Tab> Assign Budget to Customer
<Tab>Test it (OpenAI SDK)</Tab> </TabsTrigger>
</TabList> <TabsTrigger value="curl" className="flex-none rounded-none px-4 py-2">
<TabPanels> Test it (Curl)
<TabPanel> </TabsTrigger>
<TabsTrigger value="openai-sdk" className="flex-none rounded-none px-4 py-2">
Test it (OpenAI SDK)
</TabsTrigger>
</TabsList>
<TabsContent value="assign-budget">
<SyntaxHighlighter language="bash">{CREATE_END_USER_CURL_COMMAND}</SyntaxHighlighter> <SyntaxHighlighter language="bash">{CREATE_END_USER_CURL_COMMAND}</SyntaxHighlighter>
</TabPanel> </TabsContent>
<TabPanel> <TabsContent value="curl">
<SyntaxHighlighter language="bash">{CHAT_COMPLETIONS_CURL_COMMAND}</SyntaxHighlighter> <SyntaxHighlighter language="bash">{CHAT_COMPLETIONS_CURL_COMMAND}</SyntaxHighlighter>
</TabPanel> </TabsContent>
<TabPanel> <TabsContent value="openai-sdk">
<SyntaxHighlighter language="python">{OPENAI_SDK_PYTHON_CODE}</SyntaxHighlighter> <SyntaxHighlighter language="python">{OPENAI_SDK_PYTHON_CODE}</SyntaxHighlighter>
</TabPanel> </TabsContent>
</TabPanels> </Tabs>
</TabGroup>
</div> </div>
</TabPanel> </TabsContent>
</TabPanels> </Tabs>
</TabGroup>
</div> </div>
); );
}; };

View file

@ -40,14 +40,23 @@ const renderDashboard = () =>
<CacheDashboard accessToken="sk-test" token="tok" userRole="Admin" userID="u1" premiumUser={false} />, <CacheDashboard accessToken="sk-test" token="tok" userRole="Admin" userID="u1" premiumUser={false} />,
); );
const REQUESTS_CHART_TITLE = "Cache Hits vs API Requests";
const TOKENS_CHART_TITLE = "Cached Completion Tokens vs Generated Completion Tokens";
// Anchored on each chart's own title rather than on a global card count, so
// adding cards elsewhere on the page cannot silently repoint these assertions.
const cardTitled = (title: string): HTMLElement => {
const card = screen.getByText(title).closest('[data-slot="card"]');
expect(card).not.toBeNull();
return card as HTMLElement;
};
const findChartCards = async () => { const findChartCards = async () => {
await screen.findByText("Cache Hits vs API Requests"); await screen.findByText(REQUESTS_CHART_TITLE);
await waitFor(() => { await waitFor(() => {
expect(document.querySelectorAll("path.recharts-rectangle").length).toBeGreaterThan(0); expect(document.querySelectorAll("path.recharts-rectangle").length).toBeGreaterThan(0);
}); });
const cards = Array.from(document.querySelectorAll('[data-slot="card"]')); return { requestsCard: cardTitled(REQUESTS_CHART_TITLE), tokensCard: cardTitled(TOKENS_CHART_TITLE) };
expect(cards).toHaveLength(2);
return { requestsCard: cards[0] as HTMLElement, tokensCard: cards[1] as HTMLElement };
}; };
const barFills = (card: HTMLElement) => const barFills = (card: HTMLElement) =>

View file

@ -1,25 +1,24 @@
import { import { DateRangePickerValue } from "@tremor/react";
Card,
Col,
DateRangePickerValue,
Grid,
Icon,
MultiSelect,
MultiSelectItem,
Tab,
TabGroup,
TabList,
TabPanel,
TabPanels,
Text,
} from "@tremor/react";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import NotificationsManager from "@/components/molecules/notifications_manager"; import NotificationsManager from "@/components/molecules/notifications_manager";
import UsageDatePicker from "@/components/shared/usage_date_picker"; import UsageDatePicker from "@/components/shared/usage_date_picker";
import { BarChart } from "@/components/shared/charts"; import { BarChart } from "@/components/shared/charts";
import { Card as ChartCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
} from "@/components/ui/combobox";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { RefreshIcon } from "@heroicons/react/outline"; import { RefreshCw } from "lucide-react";
import { adminGlobalCacheActivity, cachingHealthCheckCall } from "@/components/networking"; import { adminGlobalCacheActivity, cachingHealthCheckCall } from "@/components/networking";
// Import the new component // Import the new component
@ -258,31 +257,42 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
} }
}; };
const statCards = [
{ label: "Cache Hit Ratio", value: `${cacheHitRatio}%` },
{ label: "Cache Hits", value: cachedResponses },
{ label: "Cached Completion Tokens", value: cachedTokens },
];
return ( return (
<TabGroup className="gap-2 p-8 h-full w-full mt-2 mb-8"> <Tabs defaultValue="analytics" className="mt-2 mb-8 w-full gap-2 p-8">
<TabList className="flex justify-between mt-2 w-full items-center"> <div className="mt-2 flex w-full items-center justify-between">
<div className="flex"> <TabsList>
<Tab>Cache Analytics</Tab> <TabsTrigger value="analytics" className="flex-none">
<Tab>Cache Health</Tab> Cache Analytics
<Tab>Cache Settings</Tab> </TabsTrigger>
<Tab>Coordination Redis</Tab> <TabsTrigger value="health" className="flex-none">
</div> Cache Health
</TabsTrigger>
<TabsTrigger value="settings" className="flex-none">
Cache Settings
</TabsTrigger>
<TabsTrigger value="coordination" className="flex-none">
Coordination Redis
</TabsTrigger>
</TabsList>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{lastRefreshed && <Text>Last Refreshed: {lastRefreshed}</Text>} {lastRefreshed && <p className="text-sm text-muted-foreground">Last Refreshed: {lastRefreshed}</p>}
<Icon <Button variant="outline" size="icon-sm" onClick={handleRefreshClick} aria-label="Refresh">
icon={RefreshIcon} // Modify as necessary for correct icon name <RefreshCw />
variant="shadow" </Button>
size="xs"
className="self-center"
onClick={handleRefreshClick}
/>
</div> </div>
</TabList> </div>
<TabPanels>
<TabPanel> <TabsContent value="analytics">
<Card> <Card>
<Text className="text-tremor-content dark:text-dark-tremor-content"> <CardContent>
<p className="text-sm text-muted-foreground">
Analytics for LiteLLM&apos;s{" "} Analytics for LiteLLM&apos;s{" "}
<a <a
href="https://docs.litellm.ai/docs/proxy/caching" href="https://docs.litellm.ai/docs/proxy/caching"
@ -303,31 +313,69 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
</a>{" "} </a>{" "}
(cached input tokens from Anthropic, OpenAI, etc.) is not shown here; see &quot;Prompt Caching (cached input tokens from Anthropic, OpenAI, etc.) is not shown here; see &quot;Prompt Caching
Metrics&quot; on the Usage page or individual requests in the Logs page. Metrics&quot; on the Usage page or individual requests in the Logs page.
</Text> </p>
<Grid numItems={3} className="gap-4 mt-4">
<Col> <div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-3">
<MultiSelect <Combobox
placeholder="Select Virtual Keys" multiple
items={uniqueApiKeys}
value={selectedApiKeys} value={selectedApiKeys}
onValueChange={setSelectedApiKeys} onValueChange={(keys: string[]) => setSelectedApiKeys(keys)}
> >
{uniqueApiKeys.map((key) => ( <ComboboxChips>
<MultiSelectItem key={key} value={key}> <ComboboxValue>
{(keys: string[]) =>
keys.map((key) => (
<ComboboxChip key={key} aria-label={key}>
{key} {key}
</MultiSelectItem> </ComboboxChip>
))} ))
</MultiSelect> }
</Col> </ComboboxValue>
<Col> <ComboboxChipsInput placeholder="Select Virtual Keys" className="border-0 bg-transparent" />
<MultiSelect placeholder="Select Models" value={selectedModels} onValueChange={setSelectedModels}> </ComboboxChips>
{uniqueModels.map((model) => ( <ComboboxContent>
<MultiSelectItem key={model} value={model}> <ComboboxEmpty>No virtual keys found</ComboboxEmpty>
<ComboboxList>
{(key: string) => (
<ComboboxItem key={key} value={key}>
{key}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<Combobox
multiple
items={uniqueModels}
value={selectedModels}
onValueChange={(models: string[]) => setSelectedModels(models)}
>
<ComboboxChips>
<ComboboxValue>
{(models: string[]) =>
models.map((model) => (
<ComboboxChip key={model} aria-label={model}>
{model} {model}
</MultiSelectItem> </ComboboxChip>
))} ))
</MultiSelect> }
</Col> </ComboboxValue>
<Col> <ComboboxChipsInput placeholder="Select Models" className="border-0 bg-transparent" />
</ComboboxChips>
<ComboboxContent>
<ComboboxEmpty>No models found</ComboboxEmpty>
<ComboboxList>
{(model: string) => (
<ComboboxItem key={model} value={model}>
{model}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<UsageDatePicker <UsageDatePicker
value={dateValue} value={dateValue}
onValueChange={(value) => { onValueChange={(value) => {
@ -335,44 +383,22 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
updateCachingData(value.from, value.to); updateCachingData(value.from, value.to);
}} }}
/> />
</Col>
</Grid>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 mt-4">
<Card>
<p className="text-tremor-default font-medium text-tremor-content dark:text-dark-tremor-content">
Cache Hit Ratio
</p>
<div className="mt-2 flex items-baseline space-x-2.5">
<p className="text-tremor-metric font-semibold text-tremor-content-strong dark:text-dark-tremor-content-strong">
{cacheHitRatio}%
</p>
</div>
</Card>
<Card>
<p className="text-tremor-default font-medium text-tremor-content dark:text-dark-tremor-content">
Cache Hits
</p>
<div className="mt-2 flex items-baseline space-x-2.5">
<p className="text-tremor-metric font-semibold text-tremor-content-strong dark:text-dark-tremor-content-strong">
{cachedResponses}
</p>
</div>
</Card>
<Card>
<p className="text-tremor-default font-medium text-tremor-content dark:text-dark-tremor-content">
Cached Completion Tokens
</p>
<div className="mt-2 flex items-baseline space-x-2.5">
<p className="text-tremor-metric font-semibold text-tremor-content-strong dark:text-dark-tremor-content-strong">
{cachedTokens}
</p>
</div>
</Card>
</div> </div>
<ChartCard className="mt-4"> <div className="mt-4 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{statCards.map((stat) => (
<Card key={stat.label}>
<CardContent>
<p className="text-sm font-medium text-muted-foreground">{stat.label}</p>
<div className="mt-2 flex items-baseline space-x-2.5">
<p className="text-3xl font-semibold">{stat.value}</p>
</div>
</CardContent>
</Card>
))}
</div>
<Card className="mt-4">
<CardHeader> <CardHeader>
<CardTitle className="text-base font-semibold">Cache Hits vs API Requests</CardTitle> <CardTitle className="text-base font-semibold">Cache Hits vs API Requests</CardTitle>
</CardHeader> </CardHeader>
@ -387,9 +413,9 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
yAxisWidth={48} yAxisWidth={48}
/> />
</CardContent> </CardContent>
</ChartCard> </Card>
<ChartCard className="mt-6"> <Card className="mt-6">
<CardHeader> <CardHeader>
<CardTitle className="text-base font-semibold"> <CardTitle className="text-base font-semibold">
Cached Completion Tokens vs Generated Completion Tokens Cached Completion Tokens vs Generated Completion Tokens
@ -406,24 +432,27 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
yAxisWidth={48} yAxisWidth={48}
/> />
</CardContent> </CardContent>
</ChartCard>
</Card> </Card>
</TabPanel> </CardContent>
<TabPanel> </Card>
</TabsContent>
<TabsContent value="health">
<CacheHealthTab <CacheHealthTab
accessToken={accessToken} accessToken={accessToken}
healthCheckResponse={healthCheckResponse} healthCheckResponse={healthCheckResponse}
runCachingHealthCheck={runCachingHealthCheck} runCachingHealthCheck={runCachingHealthCheck}
/> />
</TabPanel> </TabsContent>
<TabPanel>
<TabsContent value="settings">
<CacheSettings accessToken={accessToken} userRole={userRole} userID={userID} /> <CacheSettings accessToken={accessToken} userRole={userRole} userID={userID} />
</TabPanel> </TabsContent>
<TabPanel>
<TabsContent value="coordination">
<CoordinationRedisSettings /> <CoordinationRedisSettings />
</TabPanel> </TabsContent>
</TabPanels> </Tabs>
</TabGroup>
); );
}; };

View file

@ -0,0 +1,148 @@
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "@/../tests/test-utils";
import { CacheHealthTab } from "./cache_health";
const healthyResponse = {
status: "healthy",
ping_response: true,
set_cache_response: "success",
litellm_cache_params: JSON.stringify({ type: "redis", supported_call_types: ["acompletion"] }),
health_check_cache_params: JSON.stringify({
redis_version: "7.2.1",
namespace: "litellm-ns",
connection_kwargs: { host: "redis.internal", port: 6379 },
}),
};
const errorPayload = {
message: "Connection refused",
traceback: "Traceback (most recent call last): ...",
litellm_cache_params: { type: "redis" },
health_check_cache_params: {},
};
const errorResponse = { error: { message: JSON.stringify(errorPayload) } };
const renderTab = (overrides: Partial<React.ComponentProps<typeof CacheHealthTab>> = {}) =>
renderWithProviders(
<CacheHealthTab
{...{ accessToken: "sk-test", healthCheckResponse: "", runCachingHealthCheck: vi.fn(), ...overrides }}
/>,
);
describe("CacheHealthTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("offers a health check button and no results before one is run", () => {
renderTab();
expect(screen.getByRole("button", { name: "Run Health Check" })).toBeInTheDocument();
expect(screen.queryByText(/Cache Status:/)).not.toBeInTheDocument();
});
it("runs the health check when the button is clicked", async () => {
const runCachingHealthCheck = vi.fn();
const user = userEvent.setup();
renderTab({ runCachingHealthCheck });
await user.click(screen.getByRole("button", { name: "Run Health Check" }));
expect(runCachingHealthCheck).toHaveBeenCalledTimes(1);
});
it("shows an in-flight label and disables the button while the check runs", async () => {
const runCachingHealthCheck = vi.fn(() => new Promise<void>(() => {}));
const user = userEvent.setup();
renderTab({ runCachingHealthCheck });
await user.click(screen.getByRole("button", { name: "Run Health Check" }));
const button = await screen.findByRole("button", { name: "Running Health Check..." });
expect(button).toBeDisabled();
});
it("reports a healthy cache with its ping and set-cache results", async () => {
renderTab({ healthCheckResponse: healthyResponse });
expect(await screen.findByText("Cache Status: healthy")).toBeInTheDocument();
expect(screen.getByText("Cache Details")).toBeInTheDocument();
expect(screen.getByText("Ping Response")).toBeInTheDocument();
expect(screen.getByText("Set Cache Response")).toBeInTheDocument();
expect(screen.getByText("success")).toBeInTheDocument();
});
it("shows the Redis detail rows when the cache type is redis", async () => {
renderTab({ healthCheckResponse: healthyResponse });
expect(await screen.findByText("Redis Details")).toBeInTheDocument();
expect(screen.getByText("Redis Host")).toBeInTheDocument();
expect(screen.getByText("redis.internal")).toBeInTheDocument();
expect(screen.getByText("Redis Port")).toBeInTheDocument();
expect(screen.getByText("Redis Version")).toBeInTheDocument();
expect(screen.getByText("7.2.1")).toBeInTheDocument();
expect(screen.getByText("Namespace")).toBeInTheDocument();
expect(screen.getByText("litellm-ns")).toBeInTheDocument();
});
it("omits the Redis detail rows for a non-redis cache type", async () => {
renderTab({
healthCheckResponse: {
status: "healthy",
ping_response: true,
litellm_cache_params: JSON.stringify({ type: "local" }),
health_check_cache_params: JSON.stringify({}),
},
});
expect(await screen.findByText("Cache Status: healthy")).toBeInTheDocument();
expect(screen.queryByText("Redis Details")).not.toBeInTheDocument();
});
it("surfaces the error message and traceback when the check fails", async () => {
renderTab({ healthCheckResponse: errorResponse });
expect(await screen.findByText("Error Details")).toBeInTheDocument();
expect(screen.getByText("Error Message")).toBeInTheDocument();
expect(screen.getByText("Connection refused")).toBeInTheDocument();
expect(screen.getByText("Traceback")).toBeInTheDocument();
expect(screen.getByText("Cache Status: unhealthy")).toBeInTheDocument();
});
it("still shows the cache details section when the check failed", async () => {
renderTab({ healthCheckResponse: errorResponse });
expect(await screen.findByText("Cache Details")).toBeInTheDocument();
});
it("truncates a long value and expands it to the full value on click", async () => {
const longMessage = "M".repeat(120);
const user = userEvent.setup();
renderTab({
healthCheckResponse: {
error: { message: JSON.stringify({ message: longMessage, traceback: "short" }) },
},
});
await screen.findByText("Error Message");
expect(screen.getByText(`${"M".repeat(50)}...`)).toBeInTheDocument();
expect(screen.queryByText(longMessage)).not.toBeInTheDocument();
await user.click(screen.getAllByRole("button", { name: "▶" })[0]);
await waitFor(() => {
expect(screen.getByText(longMessage)).toBeInTheDocument();
});
});
it("offers both the summary and raw response views", async () => {
renderTab({ healthCheckResponse: healthyResponse });
expect(await screen.findByText("Summary")).toBeInTheDocument();
expect(screen.getByText("Raw Response")).toBeInTheDocument();
});
});

View file

@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import { Text, Button, TabGroup, TabList, Tab, TabPanel, TabPanels } from "@tremor/react"; import { Button } from "@/components/ui/button";
import { CheckCircleIcon, XCircleIcon, ClipboardCopyIcon } from "@heroicons/react/outline"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { CheckCircle2, XCircle, ClipboardCopy } from "lucide-react";
import { ResponseTimeIndicator } from "./response_time_indicator"; import { ResponseTimeIndicator } from "./response_time_indicator";
// Helper function to deep-parse a JSON string if possible // Helper function to deep-parse a JSON string if possible
@ -30,22 +31,26 @@ const TableClickableErrorField: React.FC<{ label: string; value: string | null |
}; };
return ( return (
<tr className="hover:bg-gray-50"> <tr className="hover:bg-muted/50">
<td className="px-4 py-2 align-top" colSpan={2}> <td className="px-4 py-2 align-top" colSpan={2}>
<div className="flex items-center justify-between group"> <div className="group flex items-center justify-between">
<div className="flex items-center flex-1"> <div className="flex flex-1 items-center">
<button onClick={() => setIsExpanded(!isExpanded)} className="text-gray-400 hover:text-gray-600 mr-2"> <button
onClick={() => setIsExpanded(!isExpanded)}
className="mr-2 text-muted-foreground hover:text-foreground"
>
{isExpanded ? "▼" : "▶"} {isExpanded ? "▼" : "▶"}
</button> </button>
<div> <div>
<div className="text-sm text-gray-600">{label}</div> <div className="text-sm text-muted-foreground">{label}</div>
<pre className="mt-1 text-sm font-mono text-gray-800 whitespace-pre-wrap"> <pre className="mt-1 font-mono text-sm whitespace-pre-wrap">{isExpanded ? safeValue : truncated}</pre>
{isExpanded ? safeValue : truncated}
</pre>
</div> </div>
</div> </div>
<button onClick={handleCopy} className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-600"> <button
<ClipboardCopyIcon className="h-4 w-4" /> onClick={handleCopy}
className="text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-foreground"
>
<ClipboardCopy className="size-4" />
</button> </button>
</div> </div>
</td> </td>
@ -151,27 +156,30 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
}; };
return ( return (
<div className="bg-white rounded-lg shadow-sm"> <div className="rounded-lg bg-card shadow-sm">
<TabGroup> <Tabs defaultValue="summary">
<TabList className="border-b border-gray-200 px-4"> <TabsList className="border-b border-border px-4">
<Tab className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-800">Summary</Tab> <TabsTrigger value="summary" className="flex-none">
<Tab className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-800">Raw Response</Tab> Summary
</TabList> </TabsTrigger>
<TabsTrigger value="raw" className="flex-none">
Raw Response
</TabsTrigger>
</TabsList>
<TabPanels> <TabsContent value="summary" className="p-4">
<TabPanel className="p-4">
<div> <div>
<div className="flex items-center mb-6"> <div className="mb-6 flex items-center">
{response?.status === "healthy" ? ( {response?.status === "healthy" ? (
<CheckCircleIcon className="h-5 w-5 text-green-500 mr-2" /> <CheckCircle2 className="mr-2 size-5 text-green-600" />
) : ( ) : (
<XCircleIcon className="h-5 w-5 text-red-500 mr-2" /> <XCircle className="mr-2 size-5 text-destructive" />
)} )}
<Text <p
className={`text-sm font-medium ${response?.status === "healthy" ? "text-green-500" : "text-red-500"}`} className={`text-sm font-medium ${response?.status === "healthy" ? "text-green-600" : "text-destructive"}`}
> >
Cache Status: {response?.status || "unhealthy"} Cache Status: {response?.status || "unhealthy"}
</Text> </p>
</div> </div>
<table className="w-full border-collapse"> <table className="w-full border-collapse">
@ -180,7 +188,7 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
{errorDetails && ( {errorDetails && (
<> <>
<tr> <tr>
<td colSpan={2} className="pt-4 pb-2 font-semibold text-red-600"> <td colSpan={2} className="pt-4 pb-2 font-semibold text-destructive">
Error Details Error Details
</td> </td>
</tr> </tr>
@ -221,10 +229,10 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
</tbody> </tbody>
</table> </table>
</div> </div>
</TabPanel> </TabsContent>
<TabPanel className="p-4"> <TabsContent value="raw" className="p-4">
<div className="bg-gray-50 rounded-md p-4 font-mono text-sm"> <div className="rounded-md bg-muted p-4 font-mono text-sm">
<pre className="whitespace-pre-wrap wrap-break-word overflow-auto max-h-[500px]"> <pre className="whitespace-pre-wrap wrap-break-word overflow-auto max-h-[500px]">
{(() => { {(() => {
try { try {
@ -254,9 +262,8 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
})()} })()}
</pre> </pre>
</div> </div>
</TabPanel> </TabsContent>
</TabPanels> </Tabs>
</TabGroup>
</div> </div>
); );
}; };
@ -282,11 +289,7 @@ export const CacheHealthTab: React.FC<{
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button <Button onClick={handleHealthCheck} disabled={isLoading}>
onClick={handleHealthCheck}
disabled={isLoading}
className="bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white text-sm px-4 py-2 rounded-md"
>
{isLoading ? "Running Health Check..." : "Run Health Check"} {isLoading ? "Running Health Check..." : "Run Health Check"}
</Button> </Button>
<ResponseTimeIndicator responseTimeMs={localResponseTimeMs} /> <ResponseTimeIndicator responseTimeMs={localResponseTimeMs} />

View file

@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Select, SelectItem } from "@tremor/react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface RedisTypeSelectorProps { interface RedisTypeSelectorProps {
redisType: string; redisType: string;
@ -7,17 +7,30 @@ interface RedisTypeSelectorProps {
onTypeChange: (type: string) => void; onTypeChange: (type: string) => void;
} }
const REDIS_TYPE_LABELS: Readonly<Record<string, string>> = {
node: "Node (Single Instance)",
cluster: "Cluster",
sentinel: "Sentinel",
semantic: "Semantic",
};
const RedisTypeSelector: React.FC<RedisTypeSelectorProps> = ({ redisType, redisTypeDescriptions, onTypeChange }) => { const RedisTypeSelector: React.FC<RedisTypeSelectorProps> = ({ redisType, redisTypeDescriptions, onTypeChange }) => {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Redis Type</label> <label className="text-sm font-medium">Redis Type</label>
<Select value={redisType} onValueChange={onTypeChange}> <Select value={redisType} onValueChange={(value) => value !== null && onTypeChange(value)}>
<SelectItem value="node">Node (Single Instance)</SelectItem> <SelectTrigger className="w-full">
<SelectItem value="cluster">Cluster</SelectItem> <SelectValue>{REDIS_TYPE_LABELS[redisType] ?? redisType}</SelectValue>
<SelectItem value="sentinel">Sentinel</SelectItem> </SelectTrigger>
<SelectItem value="semantic">Semantic</SelectItem> <SelectContent>
{Object.entries(REDIS_TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select> </Select>
<p className="text-xs text-gray-500"> <p className="text-xs text-muted-foreground">
{redisTypeDescriptions[redisType] || "Select the type of Redis deployment you're using"} {redisTypeDescriptions[redisType] || "Select the type of Redis deployment you're using"}
</p> </p>
</div> </div>

View file

@ -0,0 +1,56 @@
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "@/../tests/test-utils";
import CoordinationRedisTypeSelector from "./CoordinationRedisTypeSelector";
import { COORDINATION_REDIS_TYPE_DESCRIPTIONS } from "./coordinationRedisFields";
describe("CoordinationRedisTypeSelector", () => {
it("labels the control and shows the current selection", () => {
renderWithProviders(<CoordinationRedisTypeSelector redisType="node" onTypeChange={vi.fn()} />);
expect(screen.getByText("Redis Type")).toBeInTheDocument();
expect(screen.getByRole("combobox")).toBeInTheDocument();
expect(screen.getByText("Node (Single Instance)")).toBeInTheDocument();
});
it("shows the description for the selected type", () => {
renderWithProviders(<CoordinationRedisTypeSelector redisType="cluster" onTypeChange={vi.fn()} />);
expect(screen.getByText(COORDINATION_REDIS_TYPE_DESCRIPTIONS.cluster)).toBeInTheDocument();
});
it("switches the description when the selected type changes", () => {
const { rerender } = renderWithProviders(<CoordinationRedisTypeSelector redisType="node" onTypeChange={vi.fn()} />);
expect(screen.getByText(COORDINATION_REDIS_TYPE_DESCRIPTIONS.node)).toBeInTheDocument();
rerender(<CoordinationRedisTypeSelector redisType="sentinel" onTypeChange={vi.fn()} />);
expect(screen.getByText(COORDINATION_REDIS_TYPE_DESCRIPTIONS.sentinel)).toBeInTheDocument();
expect(screen.queryByText(COORDINATION_REDIS_TYPE_DESCRIPTIONS.node)).not.toBeInTheDocument();
});
it("reports the newly picked type to the caller", async () => {
const onTypeChange = vi.fn();
const user = userEvent.setup();
renderWithProviders(<CoordinationRedisTypeSelector redisType="node" onTypeChange={onTypeChange} />);
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByText("Cluster"));
expect(onTypeChange).toHaveBeenCalledTimes(1);
expect(onTypeChange.mock.calls[0][0]).toBe("cluster");
});
it("offers every supported coordination redis type", async () => {
const user = userEvent.setup();
renderWithProviders(<CoordinationRedisTypeSelector redisType="node" onTypeChange={vi.fn()} />);
await user.click(screen.getByRole("combobox"));
expect(await screen.findByText("Cluster")).toBeInTheDocument();
expect(screen.getByText("Sentinel")).toBeInTheDocument();
expect(screen.getAllByText("Node (Single Instance)").length).toBeGreaterThan(0);
});
});

View file

@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Select } from "antd"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { import {
COORDINATION_REDIS_TYPES, COORDINATION_REDIS_TYPES,
COORDINATION_REDIS_TYPE_DESCRIPTIONS, COORDINATION_REDIS_TYPE_DESCRIPTIONS,
@ -12,21 +12,24 @@ interface CoordinationRedisTypeSelectorProps {
onTypeChange: (type: CoordinationRedisType) => void; onTypeChange: (type: CoordinationRedisType) => void;
} }
const OPTIONS = COORDINATION_REDIS_TYPES.map((type) => ({ value: type, label: COORDINATION_REDIS_TYPE_LABELS[type] }));
const CoordinationRedisTypeSelector: React.FC<CoordinationRedisTypeSelectorProps> = ({ redisType, onTypeChange }) => ( const CoordinationRedisTypeSelector: React.FC<CoordinationRedisTypeSelectorProps> = ({ redisType, onTypeChange }) => (
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="coordination-redis-type" className="text-sm font-medium text-gray-700"> <label htmlFor="coordination-redis-type" className="text-sm font-medium">
Redis Type Redis Type
</label> </label>
<Select <Select value={redisType} onValueChange={(value) => value !== null && onTypeChange(value)}>
id="coordination-redis-type" <SelectTrigger id="coordination-redis-type" className="w-full">
value={redisType} <SelectValue>{COORDINATION_REDIS_TYPE_LABELS[redisType]}</SelectValue>
onChange={onTypeChange} </SelectTrigger>
options={OPTIONS} <SelectContent>
style={{ width: "100%" }} {COORDINATION_REDIS_TYPES.map((type) => (
/> <SelectItem key={type} value={type}>
<p className="text-xs text-gray-500">{COORDINATION_REDIS_TYPE_DESCRIPTIONS[redisType]}</p> {COORDINATION_REDIS_TYPE_LABELS[type]}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{COORDINATION_REDIS_TYPE_DESCRIPTIONS[redisType]}</p>
</div> </div>
); );

View file

@ -102,8 +102,8 @@ const CacheLeakageCard: React.FC<CacheLeakageCardProps> = ({ activity }) => {
<TooltipProvider delay={300}> <TooltipProvider delay={300}>
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div> <div className="min-w-0">
<CardTitle>Cache leakage by {dimension === "model" ? "model" : "virtual key"}</CardTitle> <CardTitle>Cache leakage by {dimension === "model" ? "model" : "virtual key"}</CardTitle>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
{subject} sending large volumes of uncached input with a low cache hit rate are likely missing prompt {subject} sending large volumes of uncached input with a low cache hit rate are likely missing prompt
@ -111,8 +111,10 @@ const CacheLeakageCard: React.FC<CacheLeakageCardProps> = ({ activity }) => {
{dimension === "model" ? " Limited to Anthropic (Claude) models, which support prompt caching." : ""} {dimension === "model" ? " Limited to Anthropic (Claude) models, which support prompt caching." : ""}
</p> </p>
</div> </div>
<div className="shrink-0">
<AdvancedDatePicker value={dateValue} onValueChange={onDateChange} /> <AdvancedDatePicker value={dateValue} onValueChange={onDateChange} />
</div> </div>
</div>
<Tabs <Tabs
value={dimension} value={dimension}
onValueChange={(value) => setDimension(value === "model" ? "model" : "key")} onValueChange={(value) => setDimension(value === "model" ? "model" : "key")}

View file

@ -101,13 +101,14 @@ export const useKeys = (
page: number, page: number,
pageSize: number, pageSize: number,
options: KeyListCallOptions = {}, options: KeyListCallOptions = {},
enabled: boolean = true,
): UseQueryResult<KeysResponse> => { ): UseQueryResult<KeysResponse> => {
const { accessToken } = useAuthorized(); const { accessToken } = useAuthorized();
return useQuery<KeysResponse>({ return useQuery<KeysResponse>({
queryKey: keyKeys.list({ page, limit: pageSize, ...options }), queryKey: keyKeys.list({ page, limit: pageSize, ...options }),
queryFn: async () => await keyListCall(accessToken!, page, pageSize, options), queryFn: async () => await keyListCall(accessToken!, page, pageSize, options),
enabled: Boolean(accessToken), enabled: Boolean(accessToken) && enabled,
staleTime: 30000, // 30 seconds staleTime: 30000, // 30 seconds
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });

View file

@ -3,13 +3,17 @@ import { renderHook, waitFor } from "@testing-library/react";
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
isAutoRouterDeployment,
selectAutoRouterModelGroups,
useAllProxyModels, useAllProxyModels,
useAutoRouterModelGroups,
useInfiniteModelInfo, useInfiniteModelInfo,
useModelHub, useModelHub,
useModelsInfo, useModelsInfo,
useSelectedTeamModels, useSelectedTeamModels,
useUserModels, useUserModels,
type AllProxyModelsResponse, type AllProxyModelsResponse,
type AutoRouterCandidateDeployment,
type PaginatedModelInfoResponse, type PaginatedModelInfoResponse,
type ProxyModel, type ProxyModel,
} from "./useModels"; } from "./useModels";
@ -918,3 +922,161 @@ describe("useInfiniteModelInfo", () => {
expect(modelInfoCall).not.toHaveBeenCalled(); expect(modelInfoCall).not.toHaveBeenCalled();
}); });
}); });
describe("isAutoRouterDeployment", () => {
const cases: [string, string | null | undefined, boolean][] = [
["base semantic auto-router", "auto_router/my_router", true],
["complexity router", "auto_router/complexity_router", true],
["adaptive router", "auto_router/adaptive_router", true],
["quality router", "auto_router/quality_router", true],
["plain provider alias", "anthropic/claude-haiku-4-5", false],
["wildcard deployment", "openai/*", false],
["name merely containing the prefix", "openai/auto_router/nope", false],
["missing model", undefined, false],
["null model", null, false],
];
it.each(cases)("returns %s -> %s", (_label, litellmParamsModel, expected) => {
expect(isAutoRouterDeployment({ model_name: "some-group", litellm_params: { model: litellmParamsModel } })).toBe(
expected,
);
});
it("returns false when litellm_params is absent", () => {
expect(isAutoRouterDeployment({ model_name: "some-group" })).toBe(false);
});
});
describe("selectAutoRouterModelGroups", () => {
it("keeps only the public model_name of auto-router deployments", () => {
const deployments: AutoRouterCandidateDeployment[] = [
{ model_name: "smart-router", litellm_params: { model: "auto_router/complexity_router" } },
{ model_name: "claude-haiku", litellm_params: { model: "anthropic/claude-haiku-4-5" } },
{ model_name: "claude-sonnet", litellm_params: { model: "anthropic/claude-sonnet-4-5" } },
{ model_name: "cheap-router", litellm_params: { model: "auto_router/adaptive_router" } },
];
expect(selectAutoRouterModelGroups(deployments)).toEqual(new Set(["smart-router", "cheap-router"]));
});
it("drops auto-router deployments that have no public model_name", () => {
expect(
selectAutoRouterModelGroups([{ model_name: "", litellm_params: { model: "auto_router/complexity_router" } }]),
).toEqual(new Set());
});
it("returns an empty set for an empty model list", () => {
expect(selectAutoRouterModelGroups([])).toEqual(new Set());
});
});
describe("useAutoRouterModelGroups", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue({
accessToken: "test-access-token",
userId: "test-user-id",
userRole: "Admin",
token: "test-token",
userEmail: "test@example.com",
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
});
});
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
it("resolves the set of auto-router model groups from the deployment list", async () => {
(modelInfoCall as any).mockResolvedValue({
data: [
{ model_name: "smart-router", litellm_params: { model: "auto_router/complexity_router" } },
{ model_name: "claude-haiku", litellm_params: { model: "anthropic/claude-haiku-4-5" } },
],
total_count: 2,
current_page: 1,
total_pages: 1,
size: 1000,
});
const { result } = renderHook(() => useAutoRouterModelGroups(), { wrapper });
await waitFor(() => expect(result.current.size).toBe(1));
expect(result.current.has("smart-router")).toBe(true);
expect(result.current.has("claude-haiku")).toBe(false);
});
it("requests a single large page when the proxy reports only one page of deployments", async () => {
(modelInfoCall as any).mockResolvedValue({
data: [],
total_count: 0,
current_page: 1,
total_pages: 1,
size: 1000,
});
renderHook(() => useAutoRouterModelGroups(), { wrapper });
await waitFor(() => expect(modelInfoCall).toHaveBeenCalled());
expect(modelInfoCall).toHaveBeenCalledWith("test-access-token", "test-user-id", "Admin", 1, 1000);
expect(modelInfoCall).toHaveBeenCalledTimes(1);
});
it("follows total_pages so an auto-router past the first page is still found", async () => {
(modelInfoCall as any).mockImplementation((_t: string, _u: string, _r: string, page: number) => {
if (page === 1) {
return Promise.resolve({
data: [{ model_name: "claude-haiku", litellm_params: { model: "anthropic/claude-haiku-4-5" } }],
total_count: 3,
current_page: 1,
total_pages: 3,
size: 1000,
});
}
if (page === 2) {
return Promise.resolve({
data: [{ model_name: "claude-sonnet", litellm_params: { model: "anthropic/claude-sonnet-4-5" } }],
total_count: 3,
current_page: 2,
total_pages: 3,
size: 1000,
});
}
return Promise.resolve({
data: [{ model_name: "late-router", litellm_params: { model: "auto_router/complexity_router" } }],
total_count: 3,
current_page: 3,
total_pages: 3,
size: 1000,
});
});
const { result } = renderHook(() => useAutoRouterModelGroups(), { wrapper });
await waitFor(() => expect(result.current.size).toBe(1));
expect(result.current.has("late-router")).toBe(true);
expect(modelInfoCall).toHaveBeenCalledTimes(3);
expect(modelInfoCall).toHaveBeenCalledWith("test-access-token", "test-user-id", "Admin", 3, 1000);
});
it("returns an empty set before the model list resolves", () => {
(modelInfoCall as any).mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => useAutoRouterModelGroups(), { wrapper });
expect(result.current.size).toBe(0);
});
it("returns an empty set when the model list request fails", async () => {
(modelInfoCall as any).mockRejectedValue(new Error("boom"));
const { result } = renderHook(() => useAutoRouterModelGroups(), { wrapper });
await waitFor(() => expect(modelInfoCall).toHaveBeenCalled());
expect(result.current.size).toBe(0);
});
});

View file

@ -24,6 +24,7 @@ export interface PaginatedModelInfoResponse {
const modelKeys = createQueryKeys("models"); const modelKeys = createQueryKeys("models");
const modelHubKeys = createQueryKeys("modelHub"); const modelHubKeys = createQueryKeys("modelHub");
const autoRouterKeys = createQueryKeys("autoRouterModelGroups");
const allProxyModelsKeys = createQueryKeys("allProxyModels"); const allProxyModelsKeys = createQueryKeys("allProxyModels");
const selectedTeamModelsKeys = createQueryKeys("selectedTeamModels"); const selectedTeamModelsKeys = createQueryKeys("selectedTeamModels");
const infiniteModelKeys = createQueryKeys("infiniteModels"); const infiniteModelKeys = createQueryKeys("infiniteModels");
@ -59,6 +60,65 @@ export const useModelsInfo = (
}); });
}; };
const AUTO_ROUTER_MODEL_PREFIX = "auto_router/";
const AUTO_ROUTER_LOOKUP_PAGE_SIZE = 1000;
const NO_AUTO_ROUTERS: ReadonlySet<string> = new Set<string>();
export interface AutoRouterCandidateDeployment {
model_name?: string | null;
litellm_params?: { model?: string | null } | null;
}
export const isAutoRouterDeployment = (deployment: AutoRouterCandidateDeployment): boolean =>
Boolean(deployment?.litellm_params?.model?.startsWith(AUTO_ROUTER_MODEL_PREFIX));
export const selectAutoRouterModelGroups = (deployments: AutoRouterCandidateDeployment[]): ReadonlySet<string> =>
new Set(
deployments
.filter(isAutoRouterDeployment)
.map((deployment) => deployment.model_name)
.filter((modelName): modelName is string => Boolean(modelName)),
);
const fetchAllModelDeployments = async (
accessToken: string,
userId: string,
userRole: string,
): Promise<AutoRouterCandidateDeployment[]> => {
const firstPage: PaginatedModelInfoResponse = await modelInfoCall(
accessToken,
userId,
userRole,
1,
AUTO_ROUTER_LOOKUP_PAGE_SIZE,
);
const totalPages = firstPage?.total_pages ?? 1;
const remainingPages = await Promise.all(
Array.from({ length: Math.max(0, totalPages - 1) }, (_unused, index) =>
modelInfoCall(accessToken, userId, userRole, index + 2, AUTO_ROUTER_LOOKUP_PAGE_SIZE),
),
);
return [firstPage, ...remainingPages].flatMap(
(page: PaginatedModelInfoResponse) => page?.data ?? [],
) as AutoRouterCandidateDeployment[];
};
export const useAutoRouterModelGroups = (): ReadonlySet<string> => {
const { accessToken, userId, userRole } = useAuthorized();
const { data } = useQuery<AutoRouterCandidateDeployment[], Error, ReadonlySet<string>>({
queryKey: autoRouterKeys.list({
filters: {
...(userId && { userId }),
...(userRole && { userRole }),
},
}),
queryFn: async () => await fetchAllModelDeployments(accessToken!, userId!, userRole!),
enabled: Boolean(accessToken && userId && userRole),
select: selectAutoRouterModelGroups,
});
return data ?? NO_AUTO_ROUTERS;
};
export const useModelHub = () => { export const useModelHub = () => {
const { accessToken } = useAuthorized(); const { accessToken } = useAuthorized();
return useQuery({ return useQuery({

View file

@ -1,6 +1,8 @@
import React from "react"; import React from "react";
import { Input, Tooltip } from "antd"; import { Info, Link as LinkIcon } from "lucide-react";
import { InfoCircleOutlined, LinkOutlined } from "@ant-design/icons"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { cn } from "@/lib/cva.config";
import { Logo } from "@/components/molecules/logo/Logo"; import { Logo } from "@/components/molecules/logo/Logo";
import githubLogo from "../../../../../public/assets/logos/github.svg"; import githubLogo from "../../../../../public/assets/logos/github.svg";
import slackLogo from "../../../../../public/assets/logos/slack.svg"; import slackLogo from "../../../../../public/assets/logos/slack.svg";
@ -61,29 +63,35 @@ const MCPLogoSelector: React.FC<MCPLogoSelectorProps> = ({ value, onChange }) =>
}; };
return ( return (
<TooltipProvider>
<div> <div>
<div className="flex items-center gap-2 mb-2"> <div className="mb-2 flex items-center gap-2">
<span className="text-sm font-medium text-gray-700">Logo</span> <span className="text-sm font-medium">Logo</span>
<Tooltip title="Select a well-known logo or paste a URL to any image. The logo is shown on the admin and chat pages."> <Tooltip>
<InfoCircleOutlined className="text-blue-400 hover:text-blue-600 cursor-help" /> <TooltipTrigger
render={<Info className="size-4 cursor-help text-muted-foreground" aria-label="About the logo" />}
/>
<TooltipContent>
Select a well-known logo or paste a URL to any image. The logo is shown on the admin and chat pages.
</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
{/* Preview */} {/* Preview */}
{value && ( {value && (
<div className="flex items-center gap-3 mb-3 p-3 bg-gray-50 rounded-lg border border-gray-200"> <div className="mb-3 flex items-center gap-3 rounded-lg border border-border bg-muted p-3">
<Logo <Logo
src={selectedWellKnown?.src ?? value} src={selectedWellKnown?.src ?? value}
label="Selected" label="Selected"
className="w-10 h-10 object-contain rounded-sm" className="h-10 w-10 rounded-sm object-contain"
/> />
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="text-xs text-gray-500 truncate">{value}</div> <div className="truncate text-xs text-muted-foreground">{value}</div>
</div> </div>
<button <button
type="button" type="button"
onClick={() => onChange?.(undefined)} onClick={() => onChange?.(undefined)}
className="text-xs text-gray-400 hover:text-red-500 cursor-pointer bg-transparent border-none" className="cursor-pointer border-none bg-transparent text-xs text-muted-foreground hover:text-destructive"
> >
✕ ✕
</button> </button>
@ -91,42 +99,47 @@ const MCPLogoSelector: React.FC<MCPLogoSelectorProps> = ({ value, onChange }) =>
)} )}
{/* Well-known logo grid */} {/* Well-known logo grid */}
<div className="grid grid-cols-10 gap-1.5 mb-3"> <div className="mb-3 grid grid-cols-10 gap-1.5">
{WELL_KNOWN_LOGOS.map((logo) => { {WELL_KNOWN_LOGOS.map((logo) => {
const isSelected = value === logo.url; const isSelected = value === logo.url;
return ( return (
<Tooltip key={logo.name} title={logo.name}> <Tooltip key={logo.name}>
<TooltipTrigger
render={
<button <button
type="button" type="button"
onClick={() => handleSelect(logo.url)} onClick={() => handleSelect(logo.url)}
className={`flex items-center justify-center p-2 rounded-lg border transition-all cursor-pointer className={cn(
${ "flex size-10 cursor-pointer items-center justify-center rounded-lg border p-2 transition-all",
isSelected isSelected ? "border-primary bg-accent shadow-xs" : "border-border hover:bg-accent",
? "border-blue-500 bg-blue-50 shadow-xs" )}
: "border-gray-200 hover:border-blue-300 hover:bg-gray-50"
}`}
style={{ width: 40, height: 40 }}
> >
<img src={logo.src} alt={logo.name} className="w-5 h-5 object-contain" /> <img src={logo.src} alt={logo.name} className="h-5 w-5 object-contain" />
</button> </button>
}
/>
<TooltipContent>{logo.name}</TooltipContent>
</Tooltip> </Tooltip>
); );
})} })}
</div> </div>
{/* Custom URL input */} {/* Custom URL input */}
<Input <InputGroup>
prefix={<LinkOutlined className="text-gray-400" />} <InputGroupAddon>
<LinkIcon className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Or paste a custom logo URL..." placeholder="Or paste a custom logo URL..."
value={value && !selectedWellKnown ? value : ""} value={value && !selectedWellKnown ? value : ""}
onChange={(e) => { onChange={(e) => {
const v = e.target.value.trim(); const v = e.target.value.trim();
onChange?.(v || undefined); onChange?.(v || undefined);
}} }}
className="rounded-lg"
size="small"
/> />
</InputGroup>
</div> </div>
</TooltipProvider>
); );
}; };

View file

@ -0,0 +1,106 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import MCPNetworkSettings from "./MCPNetworkSettings";
import {
getGeneralSettingsCall,
updateConfigFieldSetting,
deleteConfigFieldSetting,
fetchMCPClientIp,
} from "@/components/networking";
vi.mock("@/components/networking", () => ({
getGeneralSettingsCall: vi.fn(),
updateConfigFieldSetting: vi.fn(),
deleteConfigFieldSetting: vi.fn(),
fetchMCPClientIp: vi.fn(),
}));
const renderSettings = () => render(<MCPNetworkSettings accessToken="tok" />);
describe("MCPNetworkSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getGeneralSettingsCall).mockResolvedValue([]);
vi.mocked(fetchMCPClientIp).mockResolvedValue(null);
vi.mocked(updateConfigFieldSetting).mockResolvedValue(undefined);
vi.mocked(deleteConfigFieldSetting).mockResolvedValue(undefined);
});
it("renders the stored private ranges once settings load", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{ field_name: "mcp_internal_ip_ranges", field_value: ["10.0.0.0/8", "192.168.0.0/16"] },
]);
renderSettings();
expect(await screen.findByText("10.0.0.0/8")).toBeInTheDocument();
expect(screen.getByText("192.168.0.0/16")).toBeInTheDocument();
});
it("ignores unrelated config fields", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{ field_name: "some_other_setting", field_value: ["should-not-show"] },
]);
renderSettings();
await screen.findByText("Private IP Ranges");
expect(screen.queryByText("should-not-show")).not.toBeInTheDocument();
});
it("suggests the caller's /24 range from the detected client IP", async () => {
vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45");
renderSettings();
expect(await screen.findByText("203.0.113.45")).toBeInTheDocument();
expect(screen.getByText("203.0.113.0/24")).toBeInTheDocument();
});
it("exposes the suggested range as a control a keyboard user can reach and activate", async () => {
vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45");
renderSettings();
const suggested = await screen.findByRole("button", { name: /203\.0\.113\.0\/24/ });
suggested.focus();
expect(suggested).toHaveFocus();
await userEvent.keyboard("{Enter}");
await waitFor(() => expect(screen.queryByText("Suggested range:")).not.toBeInTheDocument());
});
it("adds the suggested range to the list when clicked, and stops suggesting it", async () => {
vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45");
renderSettings();
await userEvent.click(await screen.findByText("203.0.113.0/24"));
await waitFor(() => expect(screen.queryByText("Suggested range:")).not.toBeInTheDocument());
expect(screen.getByText("203.0.113.0/24")).toBeInTheDocument();
});
it("saves the configured ranges", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{ field_name: "mcp_internal_ip_ranges", field_value: ["10.0.0.0/8"] },
]);
renderSettings();
await userEvent.click(await screen.findByRole("button", { name: /Save/ }));
await waitFor(() =>
expect(updateConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_internal_ip_ranges", ["10.0.0.0/8"]),
);
expect(deleteConfigFieldSetting).not.toHaveBeenCalled();
});
it("clears the setting instead of saving an empty list", async () => {
renderSettings();
await userEvent.click(await screen.findByRole("button", { name: /Save/ }));
await waitFor(() => expect(deleteConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_internal_ip_ranges"));
expect(updateConfigFieldSetting).not.toHaveBeenCalled();
});
});

View file

@ -1,6 +1,10 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Select, Button, Card, Typography, Spin, Tag } from "antd"; import { Save, Plus, X } from "lucide-react";
import { SaveOutlined, PlusOutlined } from "@ant-design/icons"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { DeprecationBanner } from "@/components/DeprecationBanner"; import { DeprecationBanner } from "@/components/DeprecationBanner";
import { import {
getGeneralSettingsCall, getGeneralSettingsCall,
@ -9,8 +13,6 @@ import {
fetchMCPClientIp, fetchMCPClientIp,
} from "@/components/networking"; } from "@/components/networking";
const { Text } = Typography;
interface MCPNetworkSettingsProps { interface MCPNetworkSettingsProps {
accessToken: string | null; accessToken: string | null;
} }
@ -29,6 +31,7 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [privateRanges, setPrivateRanges] = useState<string[]>([]); const [privateRanges, setPrivateRanges] = useState<string[]>([]);
const [currentIp, setCurrentIp] = useState<string | null>(null); const [currentIp, setCurrentIp] = useState<string | null>(null);
const [rangeDraft, setRangeDraft] = useState("");
useEffect(() => { useEffect(() => {
loadSettings(); loadSettings();
@ -82,10 +85,22 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
} }
}; };
// Commas separate entries, matching the old tokenised input.
const commitDraft = () => {
const added = rangeDraft
.split(",")
.map((r) => r.trim())
.filter((r) => r !== "" && !privateRanges.includes(r));
if (added.length > 0) {
setPrivateRanges([...privateRanges, ...added]);
}
setRangeDraft("");
};
if (loading) { if (loading) {
return ( return (
<div className="flex justify-center py-12"> <div className="flex justify-center py-12">
<Spin /> <UiLoadingSpinner className="size-6 text-muted-foreground" />
</div> </div>
); );
} }
@ -96,55 +111,76 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
<div className="space-y-6 p-4"> <div className="space-y-6 p-4">
<DeprecationBanner featureName="MCP Network Settings and the internal-network-only flag" /> <DeprecationBanner featureName="MCP Network Settings and the internal-network-only flag" />
<div> <div>
<Text className="text-lg font-semibold">Private IP Ranges</Text> <p className="text-lg font-semibold">Private IP Ranges</p>
<p className="text-sm text-gray-500 mt-1"> <p className="mt-1 text-sm text-muted-foreground">
Define which IP ranges are part of your private network. Callers from these IPs can see all MCP servers. Define which IP ranges are part of your private network. Callers from these IPs can see all MCP servers.
Callers from any other IP can only see servers marked &quot;Available on Public Internet&quot;. Callers from any other IP can only see servers marked &quot;Available on Public Internet&quot;.
</p> </p>
</div> </div>
<Card> <Card className="p-6">
{currentIp && ( {currentIp && (
<div className="mb-4 p-3 bg-blue-50 rounded-lg"> <div className="mb-4 rounded-lg bg-muted p-3">
<Text className="text-sm text-blue-700"> <p className="text-sm">
Your current IP: <span className="font-mono font-medium">{currentIp}</span> Your current IP: <span className="font-mono font-medium">{currentIp}</span>
</Text> </p>
{suggestedRange && !privateRanges.includes(suggestedRange) && ( {suggestedRange && !privateRanges.includes(suggestedRange) && (
<div className="mt-1"> <div className="mt-1 flex items-center gap-2">
<Text className="text-sm text-blue-600">Suggested range: </Text> <p className="text-sm">Suggested range: </p>
<Tag <Button
className="cursor-pointer font-mono" variant="outline"
color="blue" size="sm"
icon={<PlusOutlined />} className="font-mono"
onClick={() => addSuggestedRange(suggestedRange)} onClick={() => addSuggestedRange(suggestedRange)}
> >
<Plus />
{suggestedRange} {suggestedRange}
</Tag> </Button>
</div> </div>
)} )}
</div> </div>
)} )}
<div className="flex items-center mb-2"> <div className="mb-2 flex items-center">
<Text className="font-medium">Your Private Network Ranges</Text> <p className="text-sm font-medium">Your Private Network Ranges</p>
</div> </div>
<Select {privateRanges.length > 0 && (
mode="tags" <div className="mb-2 flex flex-wrap gap-1.5">
value={privateRanges} {privateRanges.map((range) => (
onChange={setPrivateRanges} <Badge key={range} variant="secondary" className="font-mono">
{range}
<button
type="button"
aria-label={`Remove ${range}`}
onClick={() => setPrivateRanges(privateRanges.filter((r) => r !== range))}
className="ml-1 cursor-pointer"
>
<X className="size-3" />
</button>
</Badge>
))}
</div>
)}
<Input
value={rangeDraft}
placeholder="Leave empty to use defaults: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8" placeholder="Leave empty to use defaults: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8"
tokenSeparators={[","]} onChange={(e) => setRangeDraft(e.target.value)}
className="w-full" onBlur={commitDraft}
size="large" onKeyDown={(e) => {
allowClear if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
commitDraft();
}
}}
/> />
<p className="text-xs text-gray-400 mt-2"> <p className="mt-2 text-xs text-muted-foreground">
Enter CIDR ranges (e.g., 10.0.0.0/8). When empty, standard private IP ranges are used. Enter CIDR ranges (e.g., 10.0.0.0/8). When empty, standard private IP ranges are used.
</p> </p>
</Card> </Card>
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}> <Button onClick={handleSave} disabled={saving}>
<Save />
Save Save
</Button> </Button>
</div> </div>

View file

@ -1,19 +1,20 @@
import { type FC, type KeyboardEvent, type MouseEvent } from "react"; import { type FC, type KeyboardEvent, type MouseEvent } from "react";
import { Dropdown, Tooltip, Typography, Tag } from "antd"; import { Check, CircleAlert, Ellipsis, Trash2, Zap } from "lucide-react";
import type { MenuProps } from "antd"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { import {
CheckOutlined, DropdownMenu,
DeleteOutlined, DropdownMenuContent,
ExclamationCircleFilled, DropdownMenuItem,
MoreOutlined, DropdownMenuSeparator,
ThunderboltOutlined, DropdownMenuTrigger,
} from "@ant-design/icons"; } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/cva.config";
import { AUTH_TYPE, type MCPServer } from "@/components/mcp_tools/types"; import { AUTH_TYPE, type MCPServer } from "@/components/mcp_tools/types";
import { Logo } from "@/components/molecules/logo/Logo"; import { Logo } from "@/components/molecules/logo/Logo";
import { getMaskedAndFullUrl } from "./utils"; import { getMaskedAndFullUrl } from "./utils";
const { Text } = Typography;
interface MCPServerCardProps { interface MCPServerCardProps {
server: MCPServer; server: MCPServer;
// Per-user env-var fields this user still needs to fill in for this server. // Per-user env-var fields this user still needs to fill in for this server.
@ -73,8 +74,8 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
const needsAttention = missing.length > 0; const needsAttention = missing.length > 0;
const cardClass = needsAttention const cardClass = needsAttention
? "border-2 border-red-300 bg-red-50/40 hover:border-red-400 hover:shadow-md" ? "border-2 border-destructive/40 bg-destructive/5 hover:border-destructive/60 hover:shadow-md"
: "border border-gray-200 bg-white hover:border-gray-300 hover:shadow-md"; : "border border-border bg-card hover:shadow-md";
const url = server.url || ""; const url = server.url || "";
const { maskedUrl } = url ? getMaskedAndFullUrl(url) : { maskedUrl: "" }; const { maskedUrl } = url ? getMaskedAndFullUrl(url) : { maskedUrl: "" };
@ -105,91 +106,101 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
} }
}; };
const menuItems: MenuProps["items"] = []; const hasMenu = !!onRecheckHealth || !!onDelete;
if (onRecheckHealth) {
menuItems.push({
key: "test-connection",
label: "Test Connection",
icon: <ThunderboltOutlined />,
disabled: isRechecking,
onClick: ({ domEvent }) => {
domEvent.stopPropagation();
onRecheckHealth();
},
});
}
if (onDelete) {
if (menuItems.length > 0) {
menuItems.push({ key: "divider", type: "divider" });
}
menuItems.push({
key: "delete",
label: "Delete",
icon: <DeleteOutlined />,
danger: true,
onClick: ({ domEvent }) => {
domEvent.stopPropagation();
onDelete();
},
});
}
// Card uses role="button" + nested <button> children (Set, BYOK Connect, the // Card uses role="button" + nested <button> children (Set, BYOK Connect, the
// recheck-health Tag), so a real <button> wrapper would produce invalid // recheck-health Badge), so a real <button> wrapper would produce invalid
// nested-interactive HTML. The role + tabIndex + Enter/Space handler keeps // nested-interactive HTML. The role + tabIndex + Enter/Space handler keeps
// the whole card clickable and keyboard-accessible. // the whole card clickable and keyboard-accessible.
return ( return (
<TooltipProvider>
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={onClick} onClick={onClick}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className={`group relative flex h-full cursor-pointer flex-col gap-3 rounded-lg p-4 transition-all duration-150 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-400 ${cardClass}`} className={cn(
"group relative flex h-full cursor-pointer flex-col gap-3 rounded-lg p-4 transition-all duration-150 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
cardClass,
)}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{candidateLogo ? ( {candidateLogo ? (
<Logo src={candidateLogo} label={name} className="h-10 w-10 shrink-0 rounded-sm object-contain" /> <Logo src={candidateLogo} label={name} className="h-10 w-10 shrink-0 rounded-sm object-contain" />
) : ( ) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-sm bg-gray-100 font-semibold text-gray-500"> <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-sm bg-muted font-semibold text-muted-foreground">
{(name || "?").slice(0, 2).toUpperCase()} {(name || "?").slice(0, 2).toUpperCase()}
</div> </div>
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="block w-full truncate text-left font-semibold text-gray-900" title={name}> <div className="block w-full truncate text-left font-semibold" title={name}>
{name} {name}
</div> </div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-gray-500"> <div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
{alias && <span className="truncate">{alias}</span>} {alias && <span className="truncate">{alias}</span>}
{alias && <span className="text-gray-300">·</span>} {alias && <span>·</span>}
<Tooltip title={server.server_id}> <Tooltip>
<span className="font-mono text-blue-600">{server.server_id.slice(0, 7)}</span> <TooltipTrigger
render={<span className="font-mono text-primary">{server.server_id.slice(0, 7)}</span>}
/>
<TooltipContent>{server.server_id}</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
{menuItems.length > 0 && ( {hasMenu && (
<Dropdown menu={{ items: menuItems }} trigger={["click"]} placement="bottomRight"> <DropdownMenu>
<DropdownMenuTrigger
render={
<button <button
type="button" type="button"
onClick={stop} onClick={stop}
onKeyDown={stop} onKeyDown={stop}
aria-label="Server actions" aria-label="Server actions"
className="-mr-1 -mt-1 inline-flex h-8 w-8 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-gray-100 hover:text-blue-600" className="-mr-1 -mt-1 inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
> >
<MoreOutlined style={{ fontSize: 20 }} /> <Ellipsis className="size-5" />
</button> </button>
</Dropdown> }
/>
<DropdownMenuContent align="end">
{onRecheckHealth && (
<DropdownMenuItem
disabled={isRechecking}
onClick={(e) => {
stop(e);
onRecheckHealth();
}}
>
<Zap />
Test Connection
</DropdownMenuItem>
)}
{onRecheckHealth && onDelete && <DropdownMenuSeparator />}
{onDelete && (
<DropdownMenuItem
variant="destructive"
onClick={(e) => {
stop(e);
onDelete();
}}
>
<Trash2 />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)} )}
</div> </div>
{subtitle ? ( {subtitle ? (
<Tooltip title={subtitleTooltip}> <Tooltip>
<Text className="truncate font-mono text-xs text-gray-500" ellipsis> <TooltipTrigger render={<p className="truncate font-mono text-xs text-muted-foreground">{subtitle}</p>} />
{subtitle} <TooltipContent>{subtitleTooltip}</TooltipContent>
</Text>
</Tooltip> </Tooltip>
) : ( ) : (
// Defensive placeholder: keep the row even when no identifier is // Defensive placeholder: keep the row even when no identifier is
// available so the tag row stays vertically aligned across the grid. // available so the badge row stays vertically aligned across the grid.
<div className="h-[18px]" aria-hidden /> <div className="h-[18px]" aria-hidden />
)} )}
@ -203,32 +214,44 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
error={server.health_check_error} error={server.health_check_error}
dotClass={healthTone.dot} dotClass={healthTone.dot}
/> />
<Tag className="m-0">{displayTransport.toUpperCase()}</Tag> <Badge variant="outline">{displayTransport.toUpperCase()}</Badge>
<Tag className="m-0">{authType}</Tag> <Badge variant="outline">{authType}</Badge>
{oauthFlowUnset && ( {oauthFlowUnset && (
<Tooltip title="This OAuth server has no flow set (Machine-to-Machine vs Interactive). Open it and choose an OAuth Flow Type so LiteLLM authenticates it as you intend."> <Tooltip>
<Tag color="warning" className="m-0"> <TooltipTrigger
<span className="inline-flex items-center gap-1"> render={
<ExclamationCircleFilled /> <Badge variant="outline">
<CircleAlert />
OAuth flow not set OAuth flow not set
</span> </Badge>
</Tag> }
/>
<TooltipContent>
This OAuth server has no flow set (Machine-to-Machine vs Interactive). Open it and choose an OAuth Flow
Type so LiteLLM authenticates it as you intend.
</TooltipContent>
</Tooltip> </Tooltip>
)} )}
<Tag color={isPublic ? "green" : "orange"} className="m-0"> <Badge variant="outline">
<span className="inline-flex items-center gap-1"> <span className={cn("h-1.5 w-1.5 rounded-full", isPublic ? "bg-green-500" : "bg-orange-500")} />
<span className={`h-1.5 w-1.5 rounded-full ${isPublic ? "bg-green-500" : "bg-orange-500"}`} />
{isPublic ? "Public" : "Internal"} {isPublic ? "Public" : "Internal"}
</span> </Badge>
</Tag>
{accessGroups.slice(0, 2).map((g) => ( {accessGroups.slice(0, 2).map((g) => (
<Tooltip key={g} title={g}> <Tooltip key={g}>
<Tag className="m-0 max-w-[120px] truncate">{g}</Tag> <TooltipTrigger
render={
<Badge variant="outline" className="max-w-[120px] truncate">
{g}
</Badge>
}
/>
<TooltipContent>{g}</TooltipContent>
</Tooltip> </Tooltip>
))} ))}
{accessGroups.length > 2 && ( {accessGroups.length > 2 && (
<Tooltip title={accessGroups.slice(2).join(", ")}> <Tooltip>
<Tag className="m-0">+{accessGroups.length - 2}</Tag> <TooltipTrigger render={<Badge variant="outline">+{accessGroups.length - 2}</Badge>} />
<TooltipContent>{accessGroups.slice(2).join(", ")}</TooltipContent>
</Tooltip> </Tooltip>
)} )}
</div> </div>
@ -238,41 +261,43 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
{server.is_byok && <ByokRow connected={!!server.has_user_credential} onConnect={onByokConnect} />} {server.is_byok && <ByokRow connected={!!server.has_user_credential} onConnect={onByokConnect} />}
{needsAttention && ( {needsAttention && (
<div className="flex items-center justify-between gap-2 text-xs"> <div className="flex items-center justify-between gap-2 text-xs">
<Tooltip <Tooltip>
title={ <TooltipTrigger
<div> render={
<div className="font-semibold mb-1">Missing user fields:</div> <span className="inline-flex items-center gap-1 font-semibold text-destructive">
<CircleAlert className="size-3.5" />
{missing.length} user field
{missing.length === 1 ? "" : "s"} missing
</span>
}
/>
<TooltipContent>
<div className="mb-1 font-semibold">Missing user fields:</div>
<ul className="ml-3"> <ul className="ml-3">
{missing.map((m) => ( {missing.map((m) => (
<li key={m}>• {m}</li> <li key={m}>• {m}</li>
))} ))}
</ul> </ul>
</div> </TooltipContent>
}
>
<span className="inline-flex items-center gap-1 font-semibold text-red-700">
<ExclamationCircleFilled />
{missing.length} user field
{missing.length === 1 ? "" : "s"} missing
</span>
</Tooltip> </Tooltip>
{onOpenFillFields && ( {onOpenFillFields && (
<button <Button
type="button" variant="destructive"
size="sm"
onClick={(e) => { onClick={(e) => {
stop(e); stop(e);
onOpenFillFields(); onOpenFillFields();
}} }}
className="rounded-md bg-red-600 px-3 py-1 text-xs font-medium text-white shadow-xs transition-colors hover:bg-red-700"
> >
Set Set
</button> </Button>
)} )}
</div> </div>
)} )}
</div> </div>
)} )}
</div> </div>
</TooltipProvider>
); );
}; };
@ -297,32 +322,19 @@ const HealthChip: FC<HealthChipProps> = ({
}) => { }) => {
if (isLoadingHealth || isRechecking) { if (isLoadingHealth || isRechecking) {
return ( return (
<Tag className="m-0"> <Badge variant="outline" className="text-muted-foreground">
<span className="inline-flex items-center gap-1.5 text-xs text-gray-500"> <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground" />
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-gray-300" />
Checking Checking
</span> </Badge>
</Tag>
); );
} }
const tooltip = (
<div className="max-w-xs">
<div className="font-semibold mb-1">Health: {status}</div>
{lastCheck && <div className="text-xs mb-1">Last check: {new Date(lastCheck).toLocaleString()}</div>}
{error && (
<div className="text-xs">
<div className="font-medium text-red-300 mb-1">Error</div>
<div className="wrap-break-word">{error}</div>
</div>
)}
{!lastCheck && !error && <div className="text-xs text-gray-400">No health data</div>}
{onRecheck && <div className="mt-1 text-xs text-gray-300">Click to recheck</div>}
</div>
);
return ( return (
<Tooltip title={tooltip} placement="top"> <Tooltip>
<Tag <TooltipTrigger
className={`m-0 ${onRecheck ? "cursor-pointer hover:opacity-80" : "cursor-default"}`} render={
<Badge
variant="outline"
className={onRecheck ? "cursor-pointer hover:opacity-80" : "cursor-default"}
onClick={ onClick={
onRecheck onRecheck
? (e) => { ? (e) => {
@ -332,11 +344,23 @@ const HealthChip: FC<HealthChipProps> = ({
: undefined : undefined
} }
> >
<span className="inline-flex items-center gap-1.5"> <span className={cn("h-1.5 w-1.5 rounded-full", dotClass)} />
<span className={`h-1.5 w-1.5 rounded-full ${dotClass}`} />
{status.charAt(0).toUpperCase() + status.slice(1)} {status.charAt(0).toUpperCase() + status.slice(1)}
</span> </Badge>
</Tag> }
/>
<TooltipContent side="top" className="max-w-xs">
<div className="mb-1 font-semibold">Health: {status}</div>
{lastCheck && <div className="mb-1 text-xs">Last check: {new Date(lastCheck).toLocaleString()}</div>}
{error && (
<div className="text-xs">
<div className="mb-1 font-medium">Error</div>
<div className="wrap-break-word">{error}</div>
</div>
)}
{!lastCheck && !error && <div className="text-xs">No health data</div>}
{onRecheck && <div className="mt-1 text-xs">Click to recheck</div>}
</TooltipContent>
</Tooltip> </Tooltip>
); );
}; };
@ -350,22 +374,22 @@ const ByokRow: FC<ByokRowProps> = ({ connected, onConnect }) => {
if (connected) { if (connected) {
return ( return (
<div className="flex items-center justify-between gap-2 text-xs"> <div className="flex items-center justify-between gap-2 text-xs">
<span className="text-gray-500">BYOK credential</span> <span className="text-muted-foreground">BYOK credential</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full border border-green-200 bg-green-50 px-2 py-0.5 font-medium text-green-700"> <Badge variant="outline">
<CheckOutlined style={{ fontSize: 10 }} /> Connected <Check /> Connected
</span> </Badge>
{onConnect && ( {onConnect && (
<button <Button
type="button" variant="link"
size="sm"
onClick={(e) => { onClick={(e) => {
stop(e); stop(e);
onConnect(); onConnect();
}} }}
className="text-xs text-gray-400 transition-colors hover:text-blue-600"
> >
Update Update
</button> </Button>
)} )}
</div> </div>
</div> </div>
@ -373,20 +397,19 @@ const ByokRow: FC<ByokRowProps> = ({ connected, onConnect }) => {
} }
return ( return (
<div className="flex items-center justify-between gap-2 text-xs"> <div className="flex items-center justify-between gap-2 text-xs">
<span className="text-gray-500">BYOK credential</span> <span className="text-muted-foreground">BYOK credential</span>
{onConnect ? ( {onConnect ? (
<button <Button
type="button" size="sm"
onClick={(e) => { onClick={(e) => {
stop(e); stop(e);
onConnect(); onConnect();
}} }}
className="rounded-md bg-blue-600 px-3 py-1 text-xs font-medium text-white shadow-xs transition-colors hover:bg-blue-700"
> >
Connect Connect
</button> </Button>
) : ( ) : (
<span className="text-gray-400">—</span> <span className="text-muted-foreground">—</span>
)} )}
</div> </div>
); );

View file

@ -0,0 +1,84 @@
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import OpenAPIQuickPicker, { type OpenAPIRegistryEntry } from "./OpenAPIQuickPicker";
import { fetchOpenAPIRegistry } from "@/components/networking";
vi.mock("@/components/networking", () => ({
fetchOpenAPIRegistry: vi.fn(),
}));
const stripe: OpenAPIRegistryEntry = {
name: "stripe",
title: "Stripe",
description: "Payments API",
icon_url: "https://cdn.example.com/stripe.svg",
spec_url: "https://example.com/stripe.json",
};
const github: OpenAPIRegistryEntry = {
name: "github",
title: "GitHub",
description: "Code hosting API",
icon_url: "https://cdn.example.com/github.svg",
spec_url: "https://example.com/github.json",
};
describe("OpenAPIQuickPicker", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders one selectable entry per registry API", async () => {
vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe, github] });
render(<OpenAPIQuickPicker accessToken="tok" selectedName={null} onSelect={vi.fn()} />);
expect(await screen.findByRole("button", { name: /Stripe/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /GitHub/ })).toBeInTheDocument();
expect(screen.getByText("Popular APIs")).toBeInTheDocument();
});
it("passes the whole registry entry to onSelect when one is clicked", async () => {
vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe, github] });
const onSelect = vi.fn();
render(<OpenAPIQuickPicker accessToken="tok" selectedName={null} onSelect={onSelect} />);
await userEvent.click(await screen.findByRole("button", { name: /Stripe/ }));
expect(onSelect).toHaveBeenCalledWith(stripe);
});
it("renders nothing when the registry is empty", async () => {
vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [] });
const { container } = render(<OpenAPIQuickPicker accessToken="tok" selectedName={null} onSelect={vi.fn()} />);
await waitFor(() => expect(container).toBeEmptyDOMElement());
});
it("renders nothing when the registry fetch fails", async () => {
vi.mocked(fetchOpenAPIRegistry).mockRejectedValue(new Error("boom"));
const { container } = render(<OpenAPIQuickPicker accessToken="tok" selectedName={null} onSelect={vi.fn()} />);
await waitFor(() => expect(container).toBeEmptyDOMElement());
});
it("does not fetch without an access token", () => {
render(<OpenAPIQuickPicker accessToken={null} selectedName={null} onSelect={vi.fn()} />);
expect(fetchOpenAPIRegistry).not.toHaveBeenCalled();
});
it("falls back to a letter avatar when the icon fails to load", async () => {
vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe] });
render(<OpenAPIQuickPicker accessToken="tok" selectedName={null} onSelect={vi.fn()} />);
fireEvent.error(await screen.findByAltText("Stripe"));
await waitFor(() => expect(screen.queryByAltText("Stripe")).not.toBeInTheDocument());
expect(screen.getByText("S")).toBeInTheDocument();
});
});

View file

@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Spin } from "antd"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { cn } from "@/lib/cva.config";
import { fetchOpenAPIRegistry } from "@/components/networking"; import { fetchOpenAPIRegistry } from "@/components/networking";
export interface OpenAPIKeyTool { export interface OpenAPIKeyTool {
@ -49,9 +50,9 @@ const OpenAPIQuickPicker: React.FC<OpenAPIQuickPickerProps> = ({ accessToken, se
if (loading) { if (loading) {
return ( return (
<div className="mb-4"> <div className="mb-4">
<span className="text-sm font-medium text-gray-700">Popular APIs</span> <span className="text-sm font-medium">Popular APIs</span>
<div className="flex justify-center py-6"> <div className="flex justify-center py-6">
<Spin size="small" /> <UiLoadingSpinner className="size-5 text-muted-foreground" />
</div> </div>
</div> </div>
); );
@ -61,7 +62,7 @@ const OpenAPIQuickPicker: React.FC<OpenAPIQuickPickerProps> = ({ accessToken, se
return ( return (
<div className="mb-4"> <div className="mb-4">
<span className="text-sm font-medium text-gray-700 block mb-2">Popular APIs</span> <span className="mb-2 block text-sm font-medium">Popular APIs</span>
<div className="grid grid-cols-5 gap-2"> <div className="grid grid-cols-5 gap-2">
{apis.map((api) => { {apis.map((api) => {
@ -73,32 +74,30 @@ const OpenAPIQuickPicker: React.FC<OpenAPIQuickPickerProps> = ({ accessToken, se
type="button" type="button"
title={api.description} title={api.description}
onClick={() => onSelect(api)} onClick={() => onSelect(api)}
className={`flex flex-col items-center gap-1.5 p-3 rounded-lg border transition-all cursor-pointer className={cn(
${ "flex cursor-pointer flex-col items-center gap-1.5 rounded-lg border p-3 transition-all",
isSelected isSelected ? "border-primary bg-accent shadow-xs" : "border-border hover:bg-accent",
? "border-blue-500 bg-blue-50 shadow-xs" )}
: "border-gray-200 hover:border-blue-300 hover:bg-gray-50"
}`}
> >
{imgFailed ? ( {imgFailed ? (
<span className="w-7 h-7 rounded-full bg-gray-200 flex items-center justify-center text-sm font-bold text-gray-600"> <span className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-sm font-bold text-muted-foreground">
{api.title.charAt(0)} {api.title.charAt(0)}
</span> </span>
) : ( ) : (
<img <img
src={api.icon_url} src={api.icon_url}
alt={api.title} alt={api.title}
className="w-7 h-7 object-contain" className="h-7 w-7 object-contain"
onError={() => handleImgError(api.name)} onError={() => handleImgError(api.name)}
/> />
)} )}
<span className="text-xs text-gray-600 text-center leading-tight font-medium">{api.title}</span> <span className="text-center text-xs leading-tight font-medium text-muted-foreground">{api.title}</span>
</button> </button>
); );
})} })}
</div> </div>
<p className="text-xs text-gray-400 mt-2"> <p className="mt-2 text-xs text-muted-foreground">
Select an API to pre-fill the spec URL and OAuth 2.0 settings, or enter your own spec URL below. Select an API to pre-fill the spec URL and OAuth 2.0 settings, or enter your own spec URL below.
</p> </p>
</div> </div>

View file

@ -0,0 +1,23 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import TruePassthroughWarning from "./TruePassthroughWarning";
import { AUTH_TYPE } from "@/components/mcp_tools/types";
describe("TruePassthroughWarning", () => {
it("warns when auth type is true_passthrough", () => {
render(<TruePassthroughWarning authType={AUTH_TYPE.TRUE_PASSTHROUGH} />);
expect(screen.getByText("True Passthrough disables LiteLLM authentication for this server")).toBeInTheDocument();
expect(screen.getByText(/Anyone who can reach the gateway can call this server/)).toBeInTheDocument();
});
it("renders nothing for any other auth type", () => {
const { container } = render(<TruePassthroughWarning authType={AUTH_TYPE.OAUTH2} />);
expect(container).toBeEmptyDOMElement();
});
it("renders nothing when no auth type is set", () => {
const { container } = render(<TruePassthroughWarning authType={null} />);
expect(container).toBeEmptyDOMElement();
});
});

View file

@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { Alert } from "antd"; import { TriangleAlert } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { AUTH_TYPE } from "@/components/mcp_tools/types"; import { AUTH_TYPE } from "@/components/mcp_tools/types";
/** /**
@ -10,12 +11,15 @@ import { AUTH_TYPE } from "@/components/mcp_tools/types";
export default function TruePassthroughWarning({ authType }: { authType?: string | null }) { export default function TruePassthroughWarning({ authType }: { authType?: string | null }) {
if (authType !== AUTH_TYPE.TRUE_PASSTHROUGH) return null; if (authType !== AUTH_TYPE.TRUE_PASSTHROUGH) return null;
return ( return (
<Alert <Alert className="mb-4">
type="warning" <TriangleAlert />
showIcon <AlertTitle>True Passthrough disables LiteLLM authentication for this server</AlertTitle>
className="mb-4 rounded-lg" <AlertDescription>
message="True Passthrough disables LiteLLM authentication for this server" Anyone who can reach the gateway can call this server without a LiteLLM key. The caller&apos;s Authorization
description="Anyone who can reach the gateway can call this server without a LiteLLM key. The caller's Authorization header is forwarded to the upstream verbatim, per-key and per-team rate limits and spend tracking do not apply, and the upstream is fully responsible for authenticating callers. Choose OAuth Delegate instead if callers should still authenticate to LiteLLM." header is forwarded to the upstream verbatim, per-key and per-team rate limits and spend tracking do not apply,
/> and the upstream is fully responsible for authenticating callers. Choose OAuth Delegate instead if callers
should still authenticate to LiteLLM.
</AlertDescription>
</Alert>
); );
} }

View file

@ -1,7 +1,10 @@
import React from "react"; import React from "react";
import { Button, Spin, Alert, Collapse } from "antd"; import { CircleCheck, CircleAlert, RefreshCw, Wrench, Info } from "lucide-react";
import { CheckCircleOutlined, ExclamationCircleOutlined, ReloadOutlined, ToolOutlined } from "@ant-design/icons"; import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { Card, Title, Text } from "@tremor/react"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
interface MCPConnectionStatusProps { interface MCPConnectionStatusProps {
formValues: Record<string, any>; formValues: Record<string, any>;
@ -31,27 +34,26 @@ const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({
} }
return ( return (
<Card> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircleOutlined className="text-blue-600" /> <CircleCheck className="size-4 text-muted-foreground" />
<Title>Connection Status</Title> <h3 className="text-lg font-medium">Connection Status</h3>
</div> </div>
{!canFetchTools && (formValues.url || formValues.spec_path) && ( {!canFetchTools && (formValues.url || formValues.spec_path) && (
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed"> <div className="rounded-lg border border-dashed py-6 text-center text-muted-foreground">
<ToolOutlined className="text-2xl mb-2" /> <Wrench className="mx-auto mb-2 size-6" />
<Text>Complete required fields to test connection</Text> <p className="text-sm">Complete required fields to test connection</p>
<br /> <p className="text-sm">Fill in URL, Transport, and Authentication to test MCP server connection</p>
<Text className="text-sm">Fill in URL, Transport, and Authentication to test MCP server connection</Text>
</div> </div>
)} )}
{canFetchTools && ( {canFetchTools && (
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="mb-4 flex items-center justify-between">
<div> <div>
<Text className="text-gray-700 font-medium"> <p className="text-sm font-medium">
{isLoadingTools {isLoadingTools
? "Testing connection to MCP server..." ? "Testing connection to MCP server..."
: tools.length > 0 : tools.length > 0
@ -61,97 +63,84 @@ const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({
? "Ready to submit" ? "Ready to submit"
: "Connection failed" : "Connection failed"
: "Ready to test connection"} : "Ready to test connection"}
</Text> </p>
<br /> <p className="text-sm text-muted-foreground">Server: {formValues.url || formValues.spec_path}</p>
<Text className="text-gray-500 text-sm">Server: {formValues.url || formValues.spec_path}</Text>
</div> </div>
{isLoadingTools && ( {isLoadingTools && (
<div className="flex items-center text-blue-600"> <div className="flex items-center gap-2 text-muted-foreground">
<Spin size="small" className="mr-2" /> <UiLoadingSpinner className="size-4" />
<Text className="text-blue-600">Connecting...</Text> <p className="text-sm">Connecting...</p>
</div> </div>
)} )}
{!isLoadingTools && !toolsError && tools.length > 0 && ( {!isLoadingTools && !toolsError && tools.length > 0 && (
<div className="flex items-center text-green-600"> <div className="flex items-center gap-1">
<CheckCircleOutlined className="mr-1" /> <CircleCheck className="size-4" />
<Text className="text-green-600 font-medium">Connected</Text> <p className="text-sm font-medium">Connected</p>
</div> </div>
)} )}
{toolsError && !isPreviewForbidden && ( {toolsError && !isPreviewForbidden && (
<div className="flex items-center text-red-600"> <div className="flex items-center gap-1 text-destructive">
<ExclamationCircleOutlined className="mr-1" /> <CircleAlert className="size-4" />
<Text className="text-red-600 font-medium">Failed</Text> <p className="text-sm font-medium">Failed</p>
</div> </div>
)} )}
</div> </div>
{isLoadingTools && ( {isLoadingTools && (
<div className="flex items-center justify-center py-6"> <div className="flex items-center justify-center gap-3 py-6">
<Spin size="large" /> <UiLoadingSpinner className="size-6 text-muted-foreground" />
<Text className="ml-3">Testing connection and loading tools...</Text> <p className="text-sm">Testing connection and loading tools...</p>
</div> </div>
)} )}
{toolsError && isPreviewForbidden && ( {toolsError && isPreviewForbidden && (
<Alert message="Tool preview unavailable" description={toolsError} type="info" showIcon /> <Alert>
<Info />
<AlertTitle>Tool preview unavailable</AlertTitle>
<AlertDescription>{toolsError}</AlertDescription>
</Alert>
)} )}
{toolsError && !isPreviewForbidden && ( {toolsError && !isPreviewForbidden && (
<Alert <Alert variant="destructive">
message="Connection Failed" <CircleAlert />
description={ <AlertTitle>Connection Failed</AlertTitle>
<div> <AlertDescription>
<div>{toolsError}</div> <div>{toolsError}</div>
{toolsErrorStackTrace && ( {toolsErrorStackTrace && (
<Collapse <Collapsible className="mt-3">
items={[ <CollapsibleTrigger
{ render={
key: "stack-trace", <Button variant="link" size="sm" className="h-auto p-0">
label: "Stack Trace", Stack Trace
children: (
<pre
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontSize: "12px",
fontFamily: "monospace",
margin: 0,
padding: "8px",
backgroundColor: "#f5f5f5",
borderRadius: "4px",
maxHeight: "400px",
overflow: "auto",
}}
>
{toolsErrorStackTrace}
</pre>
),
},
]}
style={{ marginTop: "12px" }}
/>
)}
</div>
}
type="error"
showIcon
action={
<Button icon={<ReloadOutlined />} onClick={fetchTools} size="small">
Retry
</Button> </Button>
} }
/> />
<CollapsibleContent>
<pre className="mt-2 max-h-100 overflow-auto rounded-sm bg-muted p-2 font-mono text-xs break-words whitespace-pre-wrap">
{toolsErrorStackTrace}
</pre>
</CollapsibleContent>
</Collapsible>
)}
</AlertDescription>
<div className="mt-3">
<Button variant="outline" size="sm" onClick={fetchTools}>
<RefreshCw />
Retry
</Button>
</div>
</Alert>
)} )}
{!isLoadingTools && tools.length === 0 && !toolsError && ( {!isLoadingTools && tools.length === 0 && !toolsError && (
<div className="text-center py-6 text-gray-500 border rounded-lg border-dashed"> <div className="rounded-lg border border-dashed py-6 text-center">
<CheckCircleOutlined className="text-2xl mb-2 text-green-500" /> <CircleCheck className="mx-auto mb-2 size-6" />
<Text className="text-green-600 font-medium">Connection successful!</Text> <p className="text-sm font-medium">Connection successful!</p>
<br /> <p className="text-sm text-muted-foreground">No tools found for this MCP server</p>
<Text className="text-gray-500">No tools found for this MCP server</Text>
</div> </div>
)} )}
</div> </div>

View file

@ -0,0 +1,118 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import MCPDiscovery from "./mcp_discovery";
import { fetchDiscoverableMCPServers } from "@/components/networking";
import type { DiscoverableMCPServer } from "@/components/mcp_tools/types";
vi.mock("@/components/networking", () => ({
fetchDiscoverableMCPServers: vi.fn(),
}));
const githubServer = {
name: "github",
title: "GitHub",
description: "Code hosting",
category: "Developer Tools",
icon_url: "",
} as DiscoverableMCPServer;
const slackServer = {
name: "slack",
title: "Slack",
description: "Team chat",
category: "Communication",
icon_url: "",
} as DiscoverableMCPServer;
const defaultProps = {
isVisible: true,
onClose: vi.fn(),
onSelectServer: vi.fn(),
onCustomServer: vi.fn(),
accessToken: "tok",
};
describe("MCPDiscovery", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetchDiscoverableMCPServers).mockResolvedValue({
servers: [githubServer, slackServer],
categories: ["Developer Tools", "Communication"],
});
});
// Each category name renders twice: once as a filter pill (a button) and once
// as the heading of its group. Only the heading is not a button.
const groupHeading = (category: string) => screen.getAllByText(category).filter((el) => el.tagName !== "BUTTON");
it("lists every discoverable server grouped under its category", async () => {
render(<MCPDiscovery {...defaultProps} />);
expect(await screen.findByText("GitHub")).toBeInTheDocument();
expect(screen.getByText("Slack")).toBeInTheDocument();
expect(groupHeading("Developer Tools")).toHaveLength(1);
expect(groupHeading("Communication")).toHaveLength(1);
expect(screen.getByText("Add MCP Server")).toBeInTheDocument();
});
it("filters the list down to the chosen category", async () => {
render(<MCPDiscovery {...defaultProps} />);
await screen.findByText("GitHub");
await userEvent.click(screen.getByRole("button", { name: "Communication" }));
await waitFor(() => expect(screen.queryByText("GitHub")).not.toBeInTheDocument());
expect(screen.getByText("Slack")).toBeInTheDocument();
});
it("filters the list by the search term", async () => {
render(<MCPDiscovery {...defaultProps} />);
await screen.findByText("GitHub");
await userEvent.type(screen.getByPlaceholderText("Search servers..."), "chat");
await waitFor(() => expect(screen.queryByText("GitHub")).not.toBeInTheDocument());
expect(screen.getByText("Slack")).toBeInTheDocument();
});
it("hands the picked server back to the caller", async () => {
const onSelectServer = vi.fn();
render(<MCPDiscovery {...defaultProps} onSelectServer={onSelectServer} />);
await userEvent.click(await screen.findByText("GitHub"));
expect(onSelectServer).toHaveBeenCalledWith(githubServer);
});
it("offers a custom-server escape hatch", async () => {
const onCustomServer = vi.fn();
render(<MCPDiscovery {...defaultProps} onCustomServer={onCustomServer} />);
await userEvent.click(await screen.findByRole("button", { name: "+ Custom Server" }));
expect(onCustomServer).toHaveBeenCalled();
});
it("surfaces a fetch failure", async () => {
vi.mocked(fetchDiscoverableMCPServers).mockRejectedValue(new Error("registry down"));
render(<MCPDiscovery {...defaultProps} />);
expect(await screen.findByText(/Failed to load servers: registry down/)).toBeInTheDocument();
});
it("offers the custom-server link when nothing matches", async () => {
vi.mocked(fetchDiscoverableMCPServers).mockResolvedValue({ servers: [], categories: [] });
render(<MCPDiscovery {...defaultProps} />);
expect(await screen.findByText(/No servers found/)).toBeInTheDocument();
});
it("does not fetch while hidden", () => {
render(<MCPDiscovery {...defaultProps} isVisible={false} />);
expect(fetchDiscoverableMCPServers).not.toHaveBeenCalled();
});
});

View file

@ -1,13 +1,15 @@
import React, { useState, useMemo, useEffect } from "react"; import React, { useState, useMemo, useEffect } from "react";
import { Modal, Input, Typography } from "antd"; import { Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/cva.config";
import { fetchDiscoverableMCPServers } from "@/components/networking"; import { fetchDiscoverableMCPServers } from "@/components/networking";
import { DiscoverableMCPServer, DiscoverMCPServersResponse } from "@/components/mcp_tools/types"; import { DiscoverableMCPServer, DiscoverMCPServersResponse } from "@/components/mcp_tools/types";
import { mcpLogoImg } from "./create_mcp_server"; import { mcpLogoImg } from "./create_mcp_server";
import { resolveLogoSrc } from "@/lib/assetPaths"; import { resolveLogoSrc } from "@/lib/assetPaths";
const { Search } = Input;
const { Text } = Typography;
interface MCPDiscoveryProps { interface MCPDiscoveryProps {
isVisible: boolean; isVisible: boolean;
onClose: () => void; onClose: () => void;
@ -16,12 +18,21 @@ interface MCPDiscoveryProps {
accessToken: string | null; accessToken: string | null;
} }
const INITIAL_COLORS = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"]; const INITIAL_COLORS = [
"bg-blue-500",
"bg-emerald-500",
"bg-amber-500",
"bg-red-500",
"bg-violet-500",
"bg-pink-500",
"bg-cyan-500",
"bg-lime-500",
];
function getInitialAvatar(name: string) { function getInitialAvatar(name: string) {
const initial = name.charAt(0).toUpperCase(); const initial = name.charAt(0).toUpperCase();
const colorIndex = name.split("").reduce((acc, ch) => acc + ch.charCodeAt(0), 0) % INITIAL_COLORS.length; const colorIndex = name.split("").reduce((acc, ch) => acc + ch.charCodeAt(0), 0) % INITIAL_COLORS.length;
return { initial, backgroundColor: INITIAL_COLORS[colorIndex] }; return { initial, backgroundClass: INITIAL_COLORS[colorIndex] };
} }
const MCPDiscovery: React.FC<MCPDiscoveryProps> = ({ const MCPDiscovery: React.FC<MCPDiscoveryProps> = ({
@ -91,106 +102,73 @@ const MCPDiscovery: React.FC<MCPDiscoveryProps> = ({
}, [filteredServers]); }, [filteredServers]);
return ( return (
<Modal <Dialog open={isVisible} onOpenChange={(open) => !open && onClose()}>
title={ <DialogContent className="max-w-[1000px]">
<div className="flex items-center justify-between pb-4 border-b border-gray-100"> <DialogHeader>
<div className="flex items-center justify-between border-b border-border pb-4">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<img <img src={resolveLogoSrc(mcpLogoImg)} alt="MCP Logo" className="mr-2 size-5 object-contain" />
src={resolveLogoSrc(mcpLogoImg)} <DialogTitle className="text-xl font-semibold">Add MCP Server</DialogTitle>
alt="MCP Logo"
className="w-8 h-8 object-contain"
style={{
height: "20px",
width: "20px",
marginRight: "8px",
objectFit: "contain",
}}
/>
<h2 className="text-xl font-semibold text-gray-900">Add MCP Server</h2>
</div> </div>
<button <Button variant="link" size="sm" onClick={onCustomServer}>
onClick={onCustomServer}
className="text-sm text-blue-600 hover:text-blue-800 cursor-pointer bg-transparent border-none font-medium"
>
+ Custom Server + Custom Server
</button> </Button>
</div> </div>
} </DialogHeader>
open={isVisible}
onCancel={onClose} <div className="max-h-[70vh] overflow-y-auto">
footer={null}
width={1000}
className="top-8"
styles={{
body: { padding: "24px", maxHeight: "70vh", overflowY: "auto" },
header: { padding: "24px 24px 0 24px", border: "none" },
}}
>
{/* Filter pills */} {/* Filter pills */}
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 12 }}> <div className="mb-3 flex flex-wrap gap-1.5">
{["All", ...categories].map((cat) => { {["All", ...categories].map((cat) => {
const isSelected = selectedCategory === cat; const isSelected = selectedCategory === cat;
return ( return (
<button <Button
key={cat} key={cat}
size="sm"
variant={isSelected ? "default" : "outline"}
onClick={() => setSelectedCategory(cat)} onClick={() => setSelectedCategory(cat)}
style={{
padding: "4px 12px",
borderRadius: 4,
border: isSelected ? "1px solid #111827" : "1px solid #e5e7eb",
background: isSelected ? "#111827" : "#fff",
color: isSelected ? "#fff" : "#4b5563",
cursor: "pointer",
fontSize: 12,
fontWeight: isSelected ? 500 : 400,
lineHeight: "20px",
}}
> >
{cat} {cat}
</button> </Button>
); );
})} })}
</div> </div>
{/* Search */} {/* Search */}
<Search <InputGroup className="mb-4 w-full">
<InputGroupAddon>
<Search className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search servers..." placeholder="Search servers..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
style={{ marginBottom: 16 }}
allowClear
/> />
</InputGroup>
{/* Loading skeleton */} {/* Loading skeleton */}
{loading && ( {loading && (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}> <div className="flex flex-col gap-1">
{Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: 8 }).map((_, i) => (
<div <Skeleton key={i} className="h-9 rounded-md" />
key={i}
style={{
height: 36,
borderRadius: 6,
background: "#f9fafb",
}}
/>
))} ))}
</div> </div>
)} )}
{error && ( {error && (
<div style={{ textAlign: "center", padding: "32px 0", color: "#9ca3af" }}> <div className="py-8 text-center text-muted-foreground">
<Text>Failed to load servers: {error}</Text> <p className="text-sm">Failed to load servers: {error}</p>
</div> </div>
)} )}
{!loading && !error && filteredServers.length === 0 && ( {!loading && !error && filteredServers.length === 0 && (
<div style={{ textAlign: "center", padding: "32px 0", color: "#9ca3af" }}> <div className="py-8 text-center text-muted-foreground">
<Text> <p className="text-sm">
No servers found.{" "} No servers found.{" "}
<a onClick={onCustomServer} style={{ color: "#2563eb", cursor: "pointer" }}> <Button variant="link" size="sm" onClick={onCustomServer}>
Add a custom server Add a custom server
</a> </Button>
</Text> </p>
</div> </div>
)} )}
@ -198,60 +176,24 @@ const MCPDiscovery: React.FC<MCPDiscoveryProps> = ({
{!loading && {!loading &&
!error && !error &&
Object.entries(groupedServers).map(([category, categoryServers]) => ( Object.entries(groupedServers).map(([category, categoryServers]) => (
<div key={category} style={{ marginBottom: 16 }}> <div key={category} className="mb-4">
<div <div className="mb-1 border-b border-border py-1.5 text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
style={{
fontSize: 11,
fontWeight: 500,
color: "#9ca3af",
textTransform: "uppercase",
letterSpacing: "0.05em",
padding: "6px 0",
borderBottom: "1px solid #f3f4f6",
marginBottom: 4,
}}
>
{category} {category}
</div> </div>
<div <div className="grid grid-cols-2 gap-x-4">
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0 16px",
}}
>
{categoryServers.map((server) => { {categoryServers.map((server) => {
const avatar = getInitialAvatar(server.title || server.name); const avatar = getInitialAvatar(server.title || server.name);
return ( return (
<div <div
key={server.name} key={server.name}
onClick={() => onSelectServer(server)} onClick={() => onSelectServer(server)}
style={{ className="flex cursor-pointer items-center rounded-md px-2.5 py-2 transition-colors hover:bg-accent"
display: "flex",
alignItems: "center",
padding: "8px 10px",
borderRadius: 6,
cursor: "pointer",
transition: "background 0.1s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "#f9fafb";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
}}
> >
{server.icon_url ? ( {server.icon_url ? (
<img <img
src={resolveLogoSrc(server.icon_url)} src={resolveLogoSrc(server.icon_url)}
alt={server.title} alt={server.title}
style={{ className="mr-3 size-5 shrink-0 object-contain"
width: 20,
height: 20,
objectFit: "contain",
flexShrink: 0,
marginRight: 12,
}}
onError={(e) => { onError={(e) => {
const target = e.currentTarget; const target = e.currentTarget;
target.style.display = "none"; target.style.display = "none";
@ -261,44 +203,25 @@ const MCPDiscovery: React.FC<MCPDiscoveryProps> = ({
/> />
) : null} ) : null}
<div <div
style={{ className={cn(
width: 20, "mr-3 size-5 shrink-0 items-center justify-center rounded-sm text-[11px] font-semibold text-white",
height: 20, avatar.backgroundClass,
borderRadius: 4, server.icon_url ? "hidden" : "flex",
backgroundColor: avatar.backgroundColor, )}
color: "#fff",
display: server.icon_url ? "none" : "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 600,
fontSize: 11,
flexShrink: 0,
marginRight: 12,
}}
> >
{avatar.initial} {avatar.initial}
</div> </div>
<span <span className="flex-1 truncate text-sm">{server.title || server.name}</span>
style={{ <span className="ml-2 shrink-0 text-sm text-muted-foreground">&#8250;</span>
fontSize: 14,
fontWeight: 400,
color: "#111827",
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{server.title || server.name}
</span>
<span style={{ color: "#d1d5db", fontSize: 14, flexShrink: 0, marginLeft: 8 }}>&#8250;</span>
</div> </div>
); );
})} })}
</div> </div>
</div> </div>
))} ))}
</Modal> </div>
</DialogContent>
</Dialog>
); );
}; };

View file

@ -0,0 +1,87 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import MCPServerCostConfig from "./mcp_server_cost_config";
const tools = [
{ name: "search", description: "Search the index" },
{ name: "fetch", description: "Fetch a document" },
];
describe("MCPServerCostConfig", () => {
it("renders the default cost field with the current value", () => {
render(<MCPServerCostConfig value={{ default_cost_per_query: 0.02 }} tools={[]} />);
expect(screen.getByText("Cost Configuration")).toBeInTheDocument();
expect(screen.getByPlaceholderText("0.0000")).toHaveValue("0.0200");
});
it("reports the edited default cost as a number", async () => {
const onChange = vi.fn();
render(<MCPServerCostConfig value={{}} tools={[]} onChange={onChange} />);
await userEvent.type(screen.getByPlaceholderText("0.0000"), "0.5");
expect(onChange).toHaveBeenLastCalledWith({ default_cost_per_query: 0.5 });
});
it("disables the default cost field when disabled", () => {
render(<MCPServerCostConfig value={{}} tools={[]} disabled />);
expect(screen.getByPlaceholderText("0.0000")).toBeDisabled();
});
it("hides the per-tool section when the server exposes no tools", () => {
render(<MCPServerCostConfig value={{}} tools={[]} />);
expect(screen.queryByText("Available Tools")).not.toBeInTheDocument();
});
it("offers a per-tool override for every tool once tools are loaded", async () => {
render(<MCPServerCostConfig value={{}} tools={tools} />);
await userEvent.click(screen.getByText("Available Tools"));
expect(screen.getByText("search")).toBeInTheDocument();
expect(screen.getByText("Search the index")).toBeInTheDocument();
expect(screen.getByText("fetch")).toBeInTheDocument();
expect(screen.getAllByPlaceholderText("Use default")).toHaveLength(2);
});
it("merges a per-tool override into the existing cost map", async () => {
const onChange = vi.fn();
render(
<MCPServerCostConfig
value={{ default_cost_per_query: 0.01, tool_name_to_cost_per_query: { fetch: 0.2 } }}
tools={tools}
onChange={onChange}
/>,
);
await userEvent.click(screen.getByText("Available Tools"));
await userEvent.type(screen.getAllByPlaceholderText("Use default")[0], "3");
expect(onChange).toHaveBeenLastCalledWith({
default_cost_per_query: 0.01,
tool_name_to_cost_per_query: { fetch: 0.2, search: 3 },
});
});
it("summarises the configured costs", () => {
render(
<MCPServerCostConfig
value={{ default_cost_per_query: 0.01, tool_name_to_cost_per_query: { search: 0.25 } }}
tools={tools}
/>,
);
expect(screen.getByText("• Default cost: $0.0100 per query")).toBeInTheDocument();
expect(screen.getByText("• search: $0.2500 per query")).toBeInTheDocument();
});
it("shows no summary when nothing is configured", () => {
render(<MCPServerCostConfig value={{}} tools={tools} />);
expect(screen.queryByText("Cost Summary:")).not.toBeInTheDocument();
});
});

View file

@ -1,7 +1,10 @@
import React from "react"; import React, { useState } from "react";
import { Tooltip, InputNumber, Collapse, Badge } from "antd"; import { Info, DollarSign, Wrench } from "lucide-react";
import { InfoCircleOutlined, DollarOutlined, ToolOutlined } from "@ant-design/icons"; import { Badge } from "@/components/ui/badge";
import { Card, Title, Text } from "@tremor/react"; import { Card } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { MCPServerCostInfo } from "@/components/mcp_tools/types"; import { MCPServerCostInfo } from "@/components/mcp_tools/types";
interface MCPServerCostConfigProps { interface MCPServerCostConfigProps {
@ -11,6 +14,47 @@ interface MCPServerCostConfigProps {
disabled?: boolean; disabled?: boolean;
} }
interface CostInputProps {
value: number | null | undefined;
placeholder: string;
disabled?: boolean;
className?: string;
onChange: (cost: number | null) => void;
}
/**
* Costs are shown to four decimal places when idle, but the field keeps the raw
* keystrokes while it is being edited so partial input like "0." survives.
*/
const CostInput: React.FC<CostInputProps> = ({ value, placeholder, disabled, className, onChange }) => {
const [draft, setDraft] = useState<string | null>(null);
const display = draft ?? (value === null || value === undefined ? "" : value.toFixed(4));
const handleChange = (next: string) => {
setDraft(next);
const parsed = Number(next);
onChange(next.trim() === "" || Number.isNaN(parsed) ? null : parsed);
};
return (
<InputGroup className={className}>
<InputGroupAddon>
<InputGroupText>$</InputGroupText>
</InputGroupAddon>
<InputGroupInput
type="text"
inputMode="decimal"
placeholder={placeholder}
disabled={disabled}
value={display}
onFocus={() => setDraft(value === null || value === undefined ? "" : String(value))}
onBlur={() => setDraft(null)}
onChange={(e) => handleChange(e.target.value)}
/>
</InputGroup>
);
};
const MCPServerCostConfig: React.FC<MCPServerCostConfigProps> = ({ const MCPServerCostConfig: React.FC<MCPServerCostConfigProps> = ({
value = {}, value = {},
onChange, onChange,
@ -37,117 +81,118 @@ const MCPServerCostConfig: React.FC<MCPServerCostConfigProps> = ({
}; };
return ( return (
<Card> <TooltipProvider>
<Card className="p-6">
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-2 mb-4"> <div className="mb-4 flex items-center gap-2">
<DollarOutlined className="text-green-600" /> <DollarSign className="size-4 text-muted-foreground" />
<Title>Cost Configuration</Title> <h3 className="text-lg font-medium">Cost Configuration</h3>
<Tooltip title="Configure costs for this MCP server's tool calls. Set a default rate and per-tool overrides."> <Tooltip>
<InfoCircleOutlined className="text-gray-400" /> <TooltipTrigger
render={<Info className="size-4 text-muted-foreground" aria-label="About cost configuration" />}
/>
<TooltipContent>
Configure costs for this MCP server&apos;s tool calls. Set a default rate and per-tool overrides.
</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="mb-2 block text-sm font-medium">
Default Cost per Query ($) Default Cost per Query ($)
<Tooltip title="Default cost charged for each tool call to this server."> <Tooltip>
<InfoCircleOutlined className="ml-1 text-gray-400" /> <TooltipTrigger
render={
<Info className="ml-1 inline size-4 text-muted-foreground" aria-label="About the default cost" />
}
/>
<TooltipContent>Default cost charged for each tool call to this server.</TooltipContent>
</Tooltip> </Tooltip>
</label> </label>
<InputNumber <CostInput
min={0}
step={0.0001}
precision={4}
placeholder="0.0000"
value={value.default_cost_per_query} value={value.default_cost_per_query}
onChange={handleDefaultCostChange} placeholder="0.0000"
disabled={disabled} disabled={disabled}
style={{ width: "200px" }} className="w-50"
addonBefore="$" onChange={handleDefaultCostChange}
/> />
<Text className="block mt-1 text-gray-500 text-sm"> <p className="mt-1 block text-sm text-muted-foreground">
Set a default cost for all tool calls to this server Set a default cost for all tool calls to this server
</Text> </p>
</div> </div>
{tools.length > 0 && ( {tools.length > 0 && (
<div className="space-y-4"> <div className="space-y-4">
<label className="block text-sm font-medium text-gray-700"> <label className="block text-sm font-medium">
Tool-Specific Costs ($) Tool-Specific Costs ($)
<Tooltip title="Override the default cost for specific tools. Leave blank to use the default rate."> <Tooltip>
<InfoCircleOutlined className="ml-1 text-gray-400" /> <TooltipTrigger
render={
<Info className="ml-1 inline size-4 text-muted-foreground" aria-label="About per-tool costs" />
}
/>
<TooltipContent>
Override the default cost for specific tools. Leave blank to use the default rate.
</TooltipContent>
</Tooltip> </Tooltip>
</label> </label>
<Collapse <Collapsible className="rounded-lg border border-border">
items={[ <CollapsibleTrigger
{ render={
key: "1", <button type="button" className="flex w-full items-center gap-2 p-3 text-left">
label: ( <Wrench className="size-4 text-muted-foreground" />
<div className="flex items-center">
<ToolOutlined className="mr-2 text-blue-500" />
<span className="font-medium">Available Tools</span> <span className="font-medium">Available Tools</span>
<Badge <Badge variant="secondary">{tools.length}</Badge>
count={tools.length} </button>
style={{ }
backgroundColor: "#52c41a",
marginLeft: "8px",
}}
/> />
</div> <CollapsibleContent>
), <div className="max-h-64 space-y-3 overflow-y-auto p-3">
children: (
<div className="space-y-3 max-h-64 overflow-y-auto">
{tools.map((tool, index) => ( {tools.map((tool, index) => (
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"> <div key={index} className="flex items-center justify-between rounded-lg bg-muted p-3">
<div className="flex-1"> <div className="flex-1">
<Text className="font-medium text-gray-900">{tool.name}</Text> <p className="text-sm font-medium">{tool.name}</p>
{tool.description && ( {tool.description && (
<Text className="text-gray-500 text-sm block mt-1">{tool.description}</Text> <p className="mt-1 block text-sm text-muted-foreground">{tool.description}</p>
)} )}
</div> </div>
<div className="ml-4"> <div className="ml-4">
<InputNumber <CostInput
min={0}
step={0.0001}
precision={4}
placeholder="Use default"
value={value.tool_name_to_cost_per_query?.[tool.name]} value={value.tool_name_to_cost_per_query?.[tool.name]}
onChange={(cost) => handleToolCostChange(tool.name, cost)} placeholder="Use default"
disabled={disabled} disabled={disabled}
style={{ width: "120px" }} className="w-40"
addonBefore="$" onChange={(cost) => handleToolCostChange(tool.name, cost)}
/> />
</div> </div>
</div> </div>
))} ))}
</div> </div>
), </CollapsibleContent>
}, </Collapsible>
]}
/>
</div> </div>
)} )}
</div> </div>
{(value.default_cost_per_query || {(value.default_cost_per_query ||
(value.tool_name_to_cost_per_query && Object.keys(value.tool_name_to_cost_per_query).length > 0)) && ( (value.tool_name_to_cost_per_query && Object.keys(value.tool_name_to_cost_per_query).length > 0)) && (
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg"> <div className="mt-6 rounded-lg border border-border bg-muted p-4">
<Text className="text-blue-800 font-medium">Cost Summary:</Text> <p className="text-sm font-medium">Cost Summary:</p>
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
{value.default_cost_per_query && ( {value.default_cost_per_query && (
<Text className="text-blue-700"> <p className="text-sm text-muted-foreground">
• Default cost: ${value.default_cost_per_query.toFixed(4)} per query • Default cost: ${value.default_cost_per_query.toFixed(4)} per query
</Text> </p>
)} )}
{value.tool_name_to_cost_per_query && {value.tool_name_to_cost_per_query &&
Object.entries(value.tool_name_to_cost_per_query).map( Object.entries(value.tool_name_to_cost_per_query).map(
([toolName, cost]) => ([toolName, cost]) =>
cost !== null && cost !== null &&
cost !== undefined && ( cost !== undefined && (
<Text key={toolName} className="text-blue-700"> <p key={toolName} className="text-sm text-muted-foreground">
• {toolName}: ${cost.toFixed(4)} per query • {toolName}: ${cost.toFixed(4)} per query
</Text> </p>
), ),
)} )}
</div> </div>
@ -155,6 +200,7 @@ const MCPServerCostConfig: React.FC<MCPServerCostConfigProps> = ({
)} )}
</div> </div>
</Card> </Card>
</TooltipProvider>
); );
}; };

View file

@ -0,0 +1,48 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import MCPServerCostDisplay from "./mcp_server_cost_display";
describe("MCPServerCostDisplay", () => {
it("explains that calls are free when no cost config exists", () => {
render(<MCPServerCostDisplay costConfig={null} />);
expect(
screen.getByText("No cost configuration set for this server. Tool calls will be charged at $0.00 per tool call."),
).toBeInTheDocument();
});
it("treats a config with only a null default cost as unconfigured", () => {
render(<MCPServerCostDisplay costConfig={{ default_cost_per_query: null }} />);
expect(screen.getByText(/No cost configuration set for this server/)).toBeInTheDocument();
});
it("shows a zero default cost rather than falling back to the empty state", () => {
render(<MCPServerCostDisplay costConfig={{ default_cost_per_query: 0 }} />);
expect(screen.getByText("Default Cost per Query")).toBeInTheDocument();
expect(screen.getByText("$0.0000")).toBeInTheDocument();
});
it("renders the default cost to four decimal places and summarises it", () => {
render(<MCPServerCostDisplay costConfig={{ default_cost_per_query: 0.0125 }} />);
expect(screen.getByText("$0.0125")).toBeInTheDocument();
expect(screen.getByText("• Default cost: $0.0125 per query")).toBeInTheDocument();
});
it("lists each tool-specific cost and counts them in the summary", () => {
render(
<MCPServerCostDisplay
costConfig={{ tool_name_to_cost_per_query: { search: 0.5, fetch: 0.25, skipped: null } }}
/>,
);
expect(screen.getByText("search")).toBeInTheDocument();
expect(screen.getByText("$0.5000 per query")).toBeInTheDocument();
expect(screen.getByText("fetch")).toBeInTheDocument();
expect(screen.getByText("$0.2500 per query")).toBeInTheDocument();
expect(screen.queryByText("skipped")).not.toBeInTheDocument();
expect(screen.getByText("• 3 tool(s) with custom pricing")).toBeInTheDocument();
});
});

View file

@ -1,5 +1,4 @@
import React from "react"; import React from "react";
import { Text } from "@tremor/react";
import { MCPServerCostInfo } from "@/components/mcp_tools/types"; import { MCPServerCostInfo } from "@/components/mcp_tools/types";
interface MCPServerCostDisplayProps { interface MCPServerCostDisplayProps {
@ -15,12 +14,12 @@ const MCPServerCostDisplay: React.FC<MCPServerCostDisplayProps> = ({ costConfig
if (!hasCostConfig) { if (!hasCostConfig) {
return ( return (
<div className="mt-6 pt-6 border-t border-gray-200"> <div className="mt-6 border-t border-border pt-6">
<div className="space-y-4"> <div className="space-y-4">
<div className="p-4 bg-gray-50 border border-gray-200 rounded-lg"> <div className="rounded-lg border border-border bg-muted p-4">
<Text className="text-gray-600"> <p className="text-sm text-muted-foreground">
No cost configuration set for this server. Tool calls will be charged at $0.00 per tool call. No cost configuration set for this server. Tool calls will be charged at $0.00 per tool call.
</Text> </p>
</div> </div>
</div> </div>
</div> </div>
@ -28,28 +27,28 @@ const MCPServerCostDisplay: React.FC<MCPServerCostDisplayProps> = ({ costConfig
} }
return ( return (
<div className="mt-6 pt-6 border-t border-gray-200"> <div className="mt-6 border-t border-border pt-6">
<div className="space-y-4"> <div className="space-y-4">
{hasDefaultCost && {hasDefaultCost &&
costConfig?.default_cost_per_query !== undefined && costConfig?.default_cost_per_query !== undefined &&
costConfig?.default_cost_per_query !== null && ( costConfig?.default_cost_per_query !== null && (
<div> <div>
<Text className="font-medium">Default Cost per Query</Text> <p className="text-sm font-medium">Default Cost per Query</p>
<div className="text-green-600 font-mono">${costConfig.default_cost_per_query.toFixed(4)}</div> <div className="font-mono text-sm">${costConfig.default_cost_per_query.toFixed(4)}</div>
</div> </div>
)} )}
{hasToolCosts && costConfig?.tool_name_to_cost_per_query && ( {hasToolCosts && costConfig?.tool_name_to_cost_per_query && (
<div> <div>
<Text className="font-medium">Tool-Specific Costs</Text> <p className="text-sm font-medium">Tool-Specific Costs</p>
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
{Object.entries(costConfig.tool_name_to_cost_per_query).map( {Object.entries(costConfig.tool_name_to_cost_per_query).map(
([toolName, cost]) => ([toolName, cost]) =>
cost !== null && cost !== null &&
cost !== undefined && ( cost !== undefined && (
<div key={toolName} className="flex justify-between items-center p-3 bg-gray-50 rounded-lg"> <div key={toolName} className="flex items-center justify-between rounded-lg bg-muted p-3">
<Text className="font-medium">{toolName}</Text> <p className="text-sm font-medium">{toolName}</p>
<Text className="text-green-600 font-mono">${cost.toFixed(4)} per query</Text> <p className="font-mono text-sm">${cost.toFixed(4)} per query</p>
</div> </div>
), ),
)} )}
@ -57,20 +56,20 @@ const MCPServerCostDisplay: React.FC<MCPServerCostDisplayProps> = ({ costConfig
</div> </div>
)} )}
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg"> <div className="mt-4 rounded-lg border border-border bg-muted p-4">
<Text className="text-blue-800 font-medium">Cost Summary:</Text> <p className="text-sm font-medium">Cost Summary:</p>
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
{hasDefaultCost && {hasDefaultCost &&
costConfig?.default_cost_per_query !== undefined && costConfig?.default_cost_per_query !== undefined &&
costConfig?.default_cost_per_query !== null && ( costConfig?.default_cost_per_query !== null && (
<Text className="text-blue-700"> <p className="text-sm text-muted-foreground">
• Default cost: ${costConfig.default_cost_per_query.toFixed(4)} per query • Default cost: ${costConfig.default_cost_per_query.toFixed(4)} per query
</Text> </p>
)} )}
{hasToolCosts && costConfig?.tool_name_to_cost_per_query && ( {hasToolCosts && costConfig?.tool_name_to_cost_per_query && (
<Text className="text-blue-700"> <p className="text-sm text-muted-foreground">
• {Object.keys(costConfig.tool_name_to_cost_per_query).length} tool(s) with custom pricing • {Object.keys(costConfig.tool_name_to_cost_per_query).length} tool(s) with custom pricing
</Text> </p>
)} )}
</div> </div>
</div> </div>

View file

@ -0,0 +1,152 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { MCPServerView } from "./mcp_server_view";
import type { MCPServer } from "@/components/mcp_tools/types";
vi.mock(".", () => ({
MCPToolsViewer: () => <div>tools viewer</div>,
}));
vi.mock("./mcp_server_edit", () => ({
default: () => <div>edit form</div>,
EDIT_OAUTH_UI_STATE_KEY: "litellm-mcp-oauth-edit-state",
}));
const baseServer = {
server_id: "srv-1",
server_name: "demo server",
alias: "demo_alias",
description: "A demo MCP server",
transport: "http",
url: "https://example.com/mcp",
auth_type: "api_key",
} as MCPServer;
const renderView = (overrides: Partial<MCPServer> = {}, props: Record<string, unknown> = {}) =>
render(
<MCPServerView
mcpServer={{ ...baseServer, ...overrides } as MCPServer}
onBack={vi.fn()}
isProxyAdmin
isEditing={false}
accessToken="tok"
userRole="Admin"
userID="u1"
availableAccessGroups={[]}
{...props}
/>,
);
describe("MCPServerView", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// Name, alias and description each label the header and a Settings row, so
// only the server id is unique to the header.
it("shows the server identity in the header", () => {
renderView();
expect(screen.getByText("srv-1")).toBeInTheDocument();
expect(screen.getAllByText("demo server").length).toBeGreaterThan(0);
expect(screen.getAllByText("A demo MCP server").length).toBeGreaterThan(0);
expect(screen.getAllByText("demo_alias").length).toBeGreaterThan(0);
});
it("falls back to a placeholder name when the server has neither name nor alias", () => {
renderView({ server_name: undefined, alias: undefined });
expect(screen.getByText("Unnamed Server")).toBeInTheDocument();
});
// "Transport" and "Authentication" label both an Overview card and a Settings
// row, so only Overview-exclusive labels identify the Overview panel.
it("summarises the connection on the Overview tab", () => {
renderView();
expect(screen.getByText("Host URL")).toBeInTheDocument();
expect(screen.getByText("Cost Configuration")).toBeInTheDocument();
expect(screen.getAllByText("HTTP").length).toBeGreaterThan(0);
expect(screen.getAllByText("https://example.com/mcp").length).toBeGreaterThan(0);
});
it("offers a Settings tab to proxy admins only", () => {
renderView();
expect(screen.getByRole("tab", { name: "Settings" })).toBeInTheDocument();
});
it("hides the Settings tab from non-admins", () => {
renderView({}, { isProxyAdmin: false });
expect(screen.queryByRole("tab", { name: "Settings" })).not.toBeInTheDocument();
});
it("opens the tools viewer on the MCP Tools tab", async () => {
renderView();
await userEvent.click(screen.getByRole("tab", { name: "MCP Tools" }));
expect(await screen.findByText("tools viewer")).toBeInTheDocument();
});
it("shows the read-only settings summary before editing", async () => {
renderView({ allow_all_keys: true, available_on_public_internet: false });
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
expect(await screen.findByText("MCP Server Settings")).toBeInTheDocument();
expect(screen.getByText("Allow All Keys")).toBeInTheDocument();
expect(screen.getByText("Enabled")).toBeInTheDocument();
expect(screen.getByText("Internal only")).toBeInTheDocument();
expect(screen.queryByText("edit form")).not.toBeInTheDocument();
});
it("swaps in the edit form when Edit Settings is pressed", async () => {
renderView();
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
await userEvent.click(await screen.findByRole("button", { name: "Edit Settings" }));
expect(await screen.findByText("edit form")).toBeInTheDocument();
});
it("opens straight into the edit form when isEditing is set", async () => {
renderView({}, { isEditing: true });
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
expect(await screen.findByText("edit form")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Edit Settings" })).not.toBeInTheDocument();
});
it("opens on the tab named by initialTabIndex", async () => {
renderView({}, { initialTabIndex: 1 });
expect(await screen.findByText("tools viewer")).toBeInTheDocument();
});
it("returns to the server list when Back is pressed", async () => {
const onBack = vi.fn();
renderView({}, { onBack });
await userEvent.click(screen.getByRole("button", { name: /Back to All Servers/ }));
expect(onBack).toHaveBeenCalled();
});
it("lists the allowed tools, or says all tools are enabled", async () => {
renderView({ allowed_tools: ["search", "fetch"] });
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
expect(await screen.findByText("search")).toBeInTheDocument();
expect(screen.getByText("fetch")).toBeInTheDocument();
expect(screen.queryByText("All tools enabled")).not.toBeInTheDocument();
});
it("says all tools are enabled when no allowlist is stored", async () => {
renderView({ allowed_tools: [] });
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
expect(await screen.findByText("All tools enabled")).toBeInTheDocument();
});
});

View file

@ -1,6 +1,9 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { ArrowLeftIcon, EyeIcon, EyeOffIcon } from "@heroicons/react/outline"; import { ArrowLeft, Eye, EyeOff } from "lucide-react";
import { Title, Card, Button, Text, Grid, TabGroup, TabList, TabPanel, TabPanels, Tab, Icon } from "@tremor/react"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { MCPServer, handleTransport, handleAuth } from "@/components/mcp_tools/types"; import { MCPServer, handleTransport, handleAuth } from "@/components/mcp_tools/types";
// TODO: Move Tools viewer from index file // TODO: Move Tools viewer from index file
@ -11,7 +14,6 @@ import MCPServerCostDisplay from "./mcp_server_cost_display";
import { getMaskedAndFullUrl } from "./utils"; import { getMaskedAndFullUrl } from "./utils";
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
import { CheckIcon, CopyIcon } from "lucide-react"; import { CheckIcon, CopyIcon } from "lucide-react";
import { Button as AntdButton } from "antd";
interface MCPServerViewProps { interface MCPServerViewProps {
mcpServer: MCPServer; mcpServer: MCPServer;
@ -86,125 +88,111 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
} }
}; };
const getTransportBadge = (transport: string) => { const getTransportBadge = (transport: string) => <Badge variant="outline">{transport.toUpperCase()}</Badge>;
const label = transport.toUpperCase();
return (
<span className="inline-flex items-center text-sm font-medium px-2.5 py-0.5 rounded-sm border bg-gray-50 text-gray-700 border-gray-200">
{label}
</span>
);
};
const getAuthBadge = (authType: string) => { const getAuthBadge = (authType: string) => <Badge variant="outline">{authType}</Badge>;
return (
<span className="inline-flex items-center text-sm font-medium px-2.5 py-0.5 rounded-sm border bg-gray-50 text-gray-700 border-gray-200">
{authType}
</span>
);
};
return ( return (
<div className="p-4 max-w-full"> <div className="max-w-full p-4">
<div className="mb-6"> <div className="mb-6">
<Button icon={ArrowLeftIcon} variant="light" className="mb-4" onClick={onBack}> <Button variant="ghost" className="mb-4" onClick={onBack}>
<ArrowLeft />
Back to All Servers Back to All Servers
</Button> </Button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Title className="text-2xl">{mcpServer.server_name || mcpServer.alias || "Unnamed Server"}</Title> <h1 className="text-2xl font-semibold">{mcpServer.server_name || mcpServer.alias || "Unnamed Server"}</h1>
<AntdButton <Button
type="text" variant="ghost"
size="small" size="icon-sm"
icon={copiedStates["mcp-server_name"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />} aria-label="Copy server name"
onClick={() => copyToClipboard(mcpServer.server_name || mcpServer.alias, "mcp-server_name")} onClick={() => copyToClipboard(mcpServer.server_name || mcpServer.alias, "mcp-server_name")}
className={`transition-all duration-200 ${ >
copiedStates["mcp-server_name"] {copiedStates["mcp-server_name"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
? "text-green-600 bg-green-50 border-green-200" </Button>
: "text-gray-400 hover:text-gray-600 hover:bg-gray-100"
}`}
/>
{mcpServer.alias && mcpServer.server_name && mcpServer.alias !== mcpServer.server_name && ( {mcpServer.alias && mcpServer.server_name && mcpServer.alias !== mcpServer.server_name && (
<span className="ml-2 inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-sm bg-gray-100 text-gray-600 border border-gray-200 font-mono"> <Badge variant="secondary" className="ml-2 font-mono">
{mcpServer.alias} {mcpServer.alias}
</span> </Badge>
)} )}
</div> </div>
<div className="flex items-center gap-1.5 mt-1"> <div className="mt-1 flex items-center gap-1.5">
<Text className="text-gray-400 font-mono text-xs">{mcpServer.server_id}</Text> <p className="font-mono text-xs text-muted-foreground">{mcpServer.server_id}</p>
<AntdButton <Button
type="text" variant="ghost"
size="small" size="icon-sm"
icon={copiedStates["mcp-server-id"] ? <CheckIcon size={10} /> : <CopyIcon size={10} />} aria-label="Copy server id"
onClick={() => copyToClipboard(mcpServer.server_id, "mcp-server-id")} onClick={() => copyToClipboard(mcpServer.server_id, "mcp-server-id")}
className={`transition-all duration-200 ${ >
copiedStates["mcp-server-id"] {copiedStates["mcp-server-id"] ? <CheckIcon size={10} /> : <CopyIcon size={10} />}
? "text-green-600 bg-green-50 border-green-200" </Button>
: "text-gray-300 hover:text-gray-500 hover:bg-gray-50"
}`}
/>
</div> </div>
{mcpServer.description && <Text className="text-gray-500 mt-2">{mcpServer.description}</Text>} {mcpServer.description && <p className="mt-2 text-sm text-muted-foreground">{mcpServer.description}</p>}
</div> </div>
{/* TODO: magic number for index */} <Tabs value={String(selectedTabIndex)} onValueChange={(v: unknown) => setSelectedTabIndex(Number(v))}>
<TabGroup index={selectedTabIndex} onIndexChange={setSelectedTabIndex}> <TabsList className="mb-4">
<TabList className="mb-4"> <TabsTrigger value="0" className="flex-none">
{[ Overview
<Tab key="overview">Overview</Tab>, </TabsTrigger>
<Tab key="tools">MCP Tools</Tab>, <TabsTrigger value="1" className="flex-none">
...(isProxyAdmin ? [<Tab key="settings">Settings</Tab>] : []), MCP Tools
]} </TabsTrigger>
</TabList> {isProxyAdmin && (
<TabsTrigger value="2" className="flex-none">
<TabPanels> Settings
{/* Overview Panel */} </TabsTrigger>
<TabPanel>
<Grid numItems={1} numItemsSm={2} numItemsLg={3} className="gap-4">
<Card className="p-4">
<Text className="text-xs font-medium text-gray-500 uppercase tracking-wide">Transport</Text>
<div className="mt-3">
{getTransportBadge(
handleTransport(mcpServer.transport ?? undefined, mcpServer.spec_path ?? undefined),
)} )}
</TabsList>
{/* Overview Panel */}
<TabsContent value="0">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card className="p-4">
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Transport</p>
<div className="mt-3">
{getTransportBadge(handleTransport(mcpServer.transport ?? undefined, mcpServer.spec_path ?? undefined))}
</div> </div>
</Card> </Card>
<Card className="p-4"> <Card className="p-4">
<Text className="text-xs font-medium text-gray-500 uppercase tracking-wide">Authentication</Text> <p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Authentication</p>
<div className="mt-3">{getAuthBadge(handleAuth(mcpServer.auth_type ?? undefined))}</div> <div className="mt-3">{getAuthBadge(handleAuth(mcpServer.auth_type ?? undefined))}</div>
</Card> </Card>
<Card className="p-4"> <Card className="p-4">
<Text className="text-xs font-medium text-gray-500 uppercase tracking-wide">Host URL</Text> <p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Host URL</p>
<div className="mt-3 flex items-center gap-2"> <div className="mt-3 flex items-center gap-2">
<Text className="break-all overflow-wrap-anywhere font-mono text-sm"> <p className="overflow-wrap-anywhere font-mono text-sm break-all">
{renderUrlWithToggle(mcpServer.url, showFullUrl)} {renderUrlWithToggle(mcpServer.url, showFullUrl)}
</Text> </p>
{/* Only proxy admins may reveal the raw URL — non-admins {/* Only proxy admins may reveal the raw URL — non-admins
receive a sanitized server object from the backend receive a sanitized server object from the backend
with `url=null`, but hide the toggle anyway as with `url=null`, but hide the toggle anyway as
defense-in-depth in case the URL ever leaks back defense-in-depth in case the URL ever leaks back
into the response. */} into the response. */}
{hasToken && isProxyAdmin && ( {hasToken && isProxyAdmin && (
<button <Button
variant="ghost"
size="icon-sm"
aria-label={showFullUrl ? "Hide full URL" : "Show full URL"}
onClick={() => setShowFullUrl(!showFullUrl)} onClick={() => setShowFullUrl(!showFullUrl)}
className="p-1 hover:bg-gray-100 rounded-sm shrink-0"
> >
<Icon icon={showFullUrl ? EyeOffIcon : EyeIcon} size="sm" className="text-gray-500" /> {showFullUrl ? <EyeOff /> : <Eye />}
</button> </Button>
)} )}
</div> </div>
</Card> </Card>
</Grid> </div>
<Card className="mt-4 p-4"> <Card className="mt-4 p-4">
<Text className="text-xs font-medium text-gray-500 uppercase tracking-wide">Cost Configuration</Text> <p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Cost Configuration</p>
<div className="mt-3"> <div className="mt-3">
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} /> <MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
</div> </div>
</Card> </Card>
</TabPanel> </TabsContent>
{/* Tool Panel */} {/* Tool Panel */}
<TabPanel> <TabsContent value="1">
<MCPToolsViewer <MCPToolsViewer
serverId={mcpServer.server_id} serverId={mcpServer.server_id}
accessToken={accessToken} accessToken={accessToken}
@ -218,15 +206,15 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
serverAlias={mcpServer.alias} serverAlias={mcpServer.alias}
extraHeaders={mcpServer.extra_headers} extraHeaders={mcpServer.extra_headers}
/> />
</TabPanel> </TabsContent>
{/* Settings Panel */} {/* Settings Panel */}
<TabPanel> <TabsContent value="2">
<Card> <Card className="p-6">
<div className="flex justify-between items-center mb-4"> <div className="mb-4 flex items-center justify-between">
<Title>MCP Server Settings</Title> <h2 className="text-lg font-medium">MCP Server Settings</h2>
{editing ? null : ( {editing ? null : (
<Button variant="light" onClick={() => setEditing(true)}> <Button variant="outline" onClick={() => setEditing(true)}>
Edit Settings Edit Settings
</Button> </Button>
)} )}
@ -241,170 +229,156 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
availableAccessGroups={availableAccessGroups} availableAccessGroups={availableAccessGroups}
/> />
) : ( ) : (
<div className="divide-y divide-gray-100"> <div className="divide-y divide-border">
<div className="py-3 grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4 py-3">
<Text className="text-sm font-medium text-gray-500">Server Name</Text> <p className="text-sm font-medium text-muted-foreground">Server Name</p>
<div className="col-span-2 text-sm text-gray-900"> <div className="col-span-2 text-sm">
{mcpServer.server_name || <span className="text-gray-400">—</span>} {mcpServer.server_name || <span className="text-muted-foreground">—</span>}
</div> </div>
</div> </div>
<div className="py-3 grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4 py-3">
<Text className="text-sm font-medium text-gray-500">Alias</Text> <p className="text-sm font-medium text-muted-foreground">Alias</p>
<div className="col-span-2 text-sm font-mono text-gray-900"> <div className="col-span-2 font-mono text-sm">
{mcpServer.alias || <span className="text-gray-400">—</span>} {mcpServer.alias || <span className="text-muted-foreground">—</span>}
</div> </div>
</div> </div>
<div className="py-3 grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4 py-3">
<Text className="text-sm font-medium text-gray-500">Description</Text> <p className="text-sm font-medium text-muted-foreground">Description</p>
<div className="col-span-2 text-sm text-gray-900"> <div className="col-span-2 text-sm">
{mcpServer.description || <span className="text-gray-400">—</span>} {mcpServer.description || <span className="text-muted-foreground">—</span>}
</div> </div>
</div> </div>
<div className="py-3 grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4 py-3">
<Text className="text-sm font-medium text-gray-500">URL</Text> <p className="text-sm font-medium text-muted-foreground">URL</p>
<div className="col-span-2 text-sm font-mono text-gray-900 break-all flex items-center gap-2"> <div className="col-span-2 flex items-center gap-2 font-mono text-sm break-all">
{renderUrlWithToggle(mcpServer.url, showFullUrl)} {renderUrlWithToggle(mcpServer.url, showFullUrl)}
{hasToken && ( {hasToken && (
<button <Button
variant="ghost"
size="icon-sm"
aria-label={showFullUrl ? "Hide full URL" : "Show full URL"}
onClick={() => setShowFullUrl(!showFullUrl)} onClick={() => setShowFullUrl(!showFullUrl)}
className="p-1 hover:bg-gray-100 rounded-sm shrink-0"
> >
<Icon icon={showFullUrl ? EyeOffIcon : EyeIcon} size="sm" className="text-gray-500" /> {showFullUrl ? <EyeOff /> : <Eye />}
</button> </Button>
)} )}
</div> </div>
</div> </div>
<div className="py-3 grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4 py-3">
<Text className="text-sm font-medium text-gray-500">Transport</Text> <p className="text-sm font-medium text-muted-foreground">Transport</p>
<div className="col-span-2"> <div className="col-span-2">
{getTransportBadge(handleTransport(mcpServer.transport, mcpServer.spec_path))} {getTransportBadge(handleTransport(mcpServer.transport, mcpServer.spec_path))}
</div> </div>
</div> </div>
<div className="py-3 grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4 py-3">
<Text className="text-sm font-medium text-gray-500">Authentication</Text> <p className="text-sm font-medium text-muted-foreground">Authentication</p>
<div className="col-span-2">{getAuthBadge(handleAuth(mcpServer.auth_type))}</div> <div className="col-span-2">{getAuthBadge(handleAuth(mcpServer.auth_type))}</div>
</div> </div>
<div className="py-3 grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4 py-3">
<Text className="text-sm font-medium text-gray-500">Extra Headers</Text> <p className="text-sm font-medium text-muted-foreground">Extra Headers</p>
<div className="col-span-2 text-sm text-gray-900"> <div className="col-span-2 text-sm">
{mcpServer.extra_headers && mcpServer.extra_headers.length > 0 ? ( {mcpServer.extra_headers && mcpServer.extra_headers.length > 0 ? (
mcpServer.extra_headers.join(", ") mcpServer.extra_headers.join(", ")
) : ( ) : (
<span className="text-gray-400">—</span> <span className="text-muted-foreground">—</span>
)} )}
</div> </div>
</div> </div>
<div className="py-3 grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4 py-3">
<Text className="text-sm font-medium text-gray-500">Allow All Keys</Text> <p className="text-sm font-medium text-muted-foreground">Allow All Keys</p>
<div className="col-span-2"> <div className="col-span-2">
{mcpServer.allow_all_keys ? ( {mcpServer.allow_all_keys ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium"> <Badge variant="outline">
<span className="h-1.5 w-1.5 rounded-full bg-green-500"></span> <span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Enabled Enabled
</span> </Badge>
) : ( ) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-50 text-gray-600 rounded-full border border-gray-200 text-xs font-medium"> <Badge variant="outline">Disabled</Badge>
Disabled
</span>
)} )}
</div> </div>
</div> </div>
<div className="py-3 grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4 py-3">
<Text className="text-sm font-medium text-gray-500">Network Access</Text> <p className="text-sm font-medium text-muted-foreground">Network Access</p>
<div className="col-span-2"> <div className="col-span-2">
{mcpServer.available_on_public_internet ? ( {mcpServer.available_on_public_internet ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium"> <Badge variant="outline">
<span className="h-1.5 w-1.5 rounded-full bg-green-500"></span> <span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Public Public
</span> </Badge>
) : ( ) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-orange-50 text-orange-700 rounded-full border border-orange-200 text-xs font-medium"> <Badge variant="outline">
<span className="h-1.5 w-1.5 rounded-full bg-orange-500"></span> <span className="h-1.5 w-1.5 rounded-full bg-orange-500" />
Internal only Internal only
</span> </Badge>
)} )}
</div> </div>
</div> </div>
{handleAuth(mcpServer.auth_type) === "oauth2" && ( {handleAuth(mcpServer.auth_type) === "oauth2" && (
<div className="py-3 grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4 py-3">
<Text className="text-sm font-medium text-gray-500">Delegate Auth to Upstream</Text> <p className="text-sm font-medium text-muted-foreground">Delegate Auth to Upstream</p>
<div className="col-span-2"> <div className="col-span-2">
{mcpServer.delegate_auth_to_upstream ? ( {mcpServer.delegate_auth_to_upstream ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium"> <Badge variant="outline">
<span className="h-1.5 w-1.5 rounded-full bg-green-500"></span> <span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Enabled (PKCE passthrough) Enabled (PKCE passthrough)
</span> </Badge>
) : ( ) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-50 text-gray-600 rounded-full border border-gray-200 text-xs font-medium"> <Badge variant="outline">Disabled</Badge>
Disabled
</span>
)} )}
</div> </div>
</div> </div>
)} )}
{handleAuth(mcpServer.auth_type) !== "oauth2" && {handleAuth(mcpServer.auth_type) !== "oauth2" &&
Array.isArray(mcpServer.extra_headers) && Array.isArray(mcpServer.extra_headers) &&
mcpServer.extra_headers.some( mcpServer.extra_headers.some((h) => typeof h === "string" && h.toLowerCase() === "authorization") && (
(h) => typeof h === "string" && h.toLowerCase() === "authorization", <div className="grid grid-cols-3 gap-4 py-3">
) && ( <p className="text-sm font-medium text-muted-foreground">OAuth Pass-through</p>
<div className="py-3 grid grid-cols-3 gap-4">
<Text className="text-sm font-medium text-gray-500">OAuth Pass-through</Text>
<div className="col-span-2"> <div className="col-span-2">
{mcpServer.oauth_passthrough ? ( {mcpServer.oauth_passthrough ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium"> <Badge variant="outline">
<span className="h-1.5 w-1.5 rounded-full bg-green-500"></span> <span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Enabled Enabled
</span> </Badge>
) : ( ) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-50 text-gray-600 rounded-full border border-gray-200 text-xs font-medium"> <Badge variant="outline">Disabled</Badge>
Disabled
</span>
)} )}
</div> </div>
</div> </div>
)} )}
<div className="py-3 grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4 py-3">
<Text className="text-sm font-medium text-gray-500">Access Groups</Text> <p className="text-sm font-medium text-muted-foreground">Access Groups</p>
<div className="col-span-2"> <div className="col-span-2">
{mcpServer.mcp_access_groups && mcpServer.mcp_access_groups.length > 0 ? ( {mcpServer.mcp_access_groups && mcpServer.mcp_access_groups.length > 0 ? (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{mcpServer.mcp_access_groups.map((group: any, index: number) => ( {mcpServer.mcp_access_groups.map((group: any, index: number) => (
<span <Badge key={index} variant="secondary">
key={index}
className="inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-sm bg-gray-100 text-gray-700 border border-gray-200"
>
{typeof group === "string" ? group : group?.name ?? ""} {typeof group === "string" ? group : group?.name ?? ""}
</span> </Badge>
))} ))}
</div> </div>
) : ( ) : (
<span className="text-sm text-gray-400">—</span> <span className="text-sm text-muted-foreground">—</span>
)} )}
</div> </div>
</div> </div>
<div className="py-3 grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4 py-3">
<Text className="text-sm font-medium text-gray-500">Allowed Tools</Text> <p className="text-sm font-medium text-muted-foreground">Allowed Tools</p>
<div className="col-span-2"> <div className="col-span-2">
{mcpServer.allowed_tools && mcpServer.allowed_tools.length > 0 ? ( {mcpServer.allowed_tools && mcpServer.allowed_tools.length > 0 ? (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{mcpServer.allowed_tools.map((tool: string, index: number) => ( {mcpServer.allowed_tools.map((tool: string, index: number) => (
<span <Badge key={index} variant="secondary" className="font-mono">
key={index}
className="inline-flex items-center text-xs font-mono font-medium px-2 py-0.5 rounded-sm bg-blue-50 text-blue-700 border border-blue-200"
>
{tool} {tool}
</span> </Badge>
))} ))}
</div> </div>
) : ( ) : (
<span className="inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-sm bg-green-50 text-green-700 border border-green-200"> <Badge variant="outline">All tools enabled</Badge>
All tools enabled
</span>
)} )}
</div> </div>
</div> </div>
<div className="py-3 grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4 py-3">
<Text className="text-sm font-medium text-gray-500">Cost</Text> <p className="text-sm font-medium text-muted-foreground">Cost</p>
<div className="col-span-2"> <div className="col-span-2">
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} /> <MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
</div> </div>
@ -412,9 +386,8 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
</div> </div>
)} )}
</Card> </Card>
</TabPanel> </TabsContent>
</TabPanels> </Tabs>
</TabGroup>
</div> </div>
); );
}; };

View file

@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { render, waitFor, screen, fireEvent, act } from "@testing-library/react"; import { render, waitFor, screen, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import MCPServers from "./mcp_servers"; import MCPServers from "./mcp_servers";
@ -307,36 +308,15 @@ describe("MCPServers", () => {
expect(screen.getByText("Team B Server")).toBeInTheDocument(); expect(screen.getByText("Team B Server")).toBeInTheDocument();
expect(screen.getByText("Team A Server 2")).toBeInTheDocument(); expect(screen.getByText("Team A Server 2")).toBeInTheDocument();
// Find the team select dropdown by looking for the "Team" label // Find the team select by its "Team" label, then the combobox it labels
const teamLabel = screen.getByText("Team"); const teamLabel = screen.getByText("Team");
const teamSelectContainer = teamLabel.closest("div")?.querySelector(".ant-select"); const teamSelect = within(teamLabel.parentElement!).getByRole("combobox");
expect(teamSelectContainer).toBeTruthy();
// Open the dropdown by clicking on the selector await userEvent.click(teamSelect);
const selectSelector = teamSelectContainer?.querySelector(".ant-select-selector");
expect(selectSelector).toBeTruthy();
act(() => { // Pick the "Team A" option once the listbox opens
fireEvent.mouseDown(selectSelector!); const teamAOption = await screen.findByText("Team A");
}); await userEvent.click(teamAOption);
// Wait for dropdown to open
await waitFor(
() => {
const dropdownOptions = document.querySelectorAll(".ant-select-item-option");
expect(dropdownOptions.length).toBeGreaterThan(0);
},
{ timeout: 5000 },
);
// Find and click on "Team A" option
const dropdownOptions = document.querySelectorAll(".ant-select-item-option");
const teamAOption = Array.from(dropdownOptions).find((option) => option.textContent?.includes("Team A"));
expect(teamAOption).toBeTruthy();
act(() => {
fireEvent.click(teamAOption!);
});
// Wait for filtering to complete // Wait for filtering to complete
await waitFor(() => { await waitFor(() => {

View file

@ -1,8 +1,21 @@
import { isAdminRole } from "@/utils/roles"; import { isAdminRole } from "@/utils/roles";
import { QuestionCircleOutlined, SearchOutlined } from "@ant-design/icons"; import { CircleHelp, Search } from "lucide-react";
import { Button, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import NewBadge from "@/components/common_components/NewBadge"; import NewBadge from "@/components/common_components/NewBadge";
import { Descriptions, Empty, Input, Modal, Select, Spin, Tooltip, Typography } from "antd";
import React, { useEffect, useState, useMemo, useCallback } from "react"; import React, { useEffect, useState, useMemo, useCallback } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers"; import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
@ -75,7 +88,6 @@ const compareServers = (a: MCPServer, b: MCPServer, sort: SortKey): number => {
} }
}; };
const { Text: AntdText, Title: AntdTitle } = Typography;
const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state"; const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state";
// Server id stashed by the Tools tab before an OBO OAuth redirect, read once at // Server id stashed by the Tools tab before an OBO OAuth redirect, read once at
@ -95,8 +107,6 @@ const readToolsOAuthServerId = (): string | null => {
} }
}; };
const { Option } = Select;
const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID }) => { const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID }) => {
const { data: mcpServers, isLoading: isLoadingServers, refetch } = useMCPServers(); const { data: mcpServers, isLoading: isLoadingServers, refetch } = useMCPServers();
@ -240,6 +250,15 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
}, [serversWithHealth]); }, [serversWithHealth]);
// Get unique MCP access groups from all servers // Get unique MCP access groups from all servers
const teamSelectItems = React.useMemo(
() => ({
all: isInternalUser ? "All Available Servers" : "All Servers",
personal: "Personal",
...Object.fromEntries(uniqueTeams.map((team) => [team.team_id, team.team_alias || team.team_id])),
}),
[isInternalUser, uniqueTeams],
);
const uniqueMcpAccessGroups = React.useMemo(() => { const uniqueMcpAccessGroups = React.useMemo(() => {
if (!serversWithHealth) return []; if (!serversWithHealth) return [];
return Array.from( return Array.from(
@ -251,6 +270,14 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
); );
}, [serversWithHealth]); }, [serversWithHealth]);
const accessGroupSelectItems = React.useMemo(
() => ({
all: "All Access Groups",
...Object.fromEntries(uniqueMcpAccessGroups.map((group) => [group, group])),
}),
[uniqueMcpAccessGroups],
);
// Filtering logic for both team and access group // Filtering logic for both team and access group
const filterServers = useCallback( const filterServers = useCallback(
(teamId: string, group: string) => { (teamId: string, group: string) => {
@ -390,50 +417,47 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
} }
return ( return (
<div className="w-full h-full p-6"> <TooltipProvider>
<Modal <div className="h-full w-full p-6">
open={isDeleteModalOpen} <AlertDialog open={isDeleteModalOpen} onOpenChange={(open) => !open && cancelDelete()}>
title="Delete MCP Server?" <AlertDialogContent>
onOk={confirmDelete} <AlertDialogHeader>
okText={isDeletingServer ? "Deleting..." : "Delete"} <AlertDialogTitle>Delete MCP Server?</AlertDialogTitle>
onCancel={cancelDelete} </AlertDialogHeader>
cancelText="Cancel"
cancelButtonProps={{ disabled: isDeletingServer }}
okButtonProps={{ danger: true }}
confirmLoading={isDeletingServer}
>
<div className="space-y-4"> <div className="space-y-4">
<AntdText className="text-gray-600"> <p className="text-sm text-muted-foreground">
This action is permanent and cannot be undone. All associated configurations will be removed. This action is permanent and cannot be undone. All associated configurations will be removed.
</AntdText> </p>
{serverToDelete && ( {serverToDelete && (
<div className="mt-3 p-4 bg-gray-50 rounded-lg border border-gray-200"> <dl className="mt-3 space-y-1 rounded-lg border border-border bg-muted p-4">
<Descriptions column={1} size="small" colon={false}>
{serverToDelete.server_name && ( {serverToDelete.server_name && (
<Descriptions.Item label={<span className="text-gray-500 text-sm">Name</span>}> <div className="flex gap-2">
<AntdText strong className="text-sm"> <dt className="text-sm text-muted-foreground">Name</dt>
{serverToDelete.server_name} <dd className="text-sm font-semibold">{serverToDelete.server_name}</dd>
</AntdText> </div>
</Descriptions.Item>
)} )}
<Descriptions.Item label={<span className="text-gray-500 text-sm">ID</span>}> <div className="flex gap-2">
<AntdText code className="text-xs"> <dt className="text-sm text-muted-foreground">ID</dt>
{serverToDelete.server_id} <dd className="font-mono text-xs">{serverToDelete.server_id}</dd>
</AntdText> </div>
</Descriptions.Item>
{serverToDelete.url && ( {serverToDelete.url && (
<Descriptions.Item label={<span className="text-gray-500 text-sm">URL</span>}> <div className="flex gap-2">
<AntdText code className="text-xs break-all"> <dt className="text-sm text-muted-foreground">URL</dt>
{serverToDelete.url} <dd className="font-mono text-xs break-all">{serverToDelete.url}</dd>
</AntdText>
</Descriptions.Item>
)}
</Descriptions>
</div> </div>
)} )}
</dl>
)}
</div> </div>
</Modal> <AlertDialogFooter>
<AlertDialogCancel disabled={isDeletingServer}>Cancel</AlertDialogCancel>
<Button variant="destructive" disabled={isDeletingServer} onClick={confirmDelete}>
{isDeletingServer ? "Deleting..." : "Delete"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<CreateMCPServer <CreateMCPServer
userRole={userRole} userRole={userRole}
userID={userID} userID={userID}
@ -452,14 +476,10 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Title>MCP Servers</Title> <h1 className="text-xl font-semibold">MCP Servers</h1>
{filteredServers.length > 0 && ( {filteredServers.length > 0 && <Badge variant="secondary">{filteredServers.length}</Badge>}
<span className="inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 border border-gray-200">
{filteredServers.length}
</span>
)}
</div> </div>
<Text className="text-tremor-content mt-1">Configure and manage your MCP servers</Text> <p className="mt-1 text-sm text-muted-foreground">Configure and manage your MCP servers</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isAdminRole(userRole) && ( {isAdminRole(userRole) && (
@ -496,25 +516,36 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
}} }}
accessToken={accessToken} accessToken={accessToken}
/> />
<TabGroup className="w-full h-full"> <Tabs defaultValue="servers" className="mt-2 w-full">
<TabList className="flex justify-between mt-2 w-full items-center"> <TabsList className="w-full justify-start">
<div className="flex"> <TabsTrigger value="servers" className="flex-none">
<Tab>All Servers</Tab> All Servers
<Tab>Toolsets</Tab> </TabsTrigger>
<Tab>Connect</Tab> <TabsTrigger value="toolsets" className="flex-none">
{isAdminRole(userRole) && <Tab>Semantic Filter</Tab>} Toolsets
{isAdminRole(userRole) && <Tab>Network Settings</Tab>} </TabsTrigger>
<TabsTrigger value="connect" className="flex-none">
Connect
</TabsTrigger>
{isAdminRole(userRole) && ( {isAdminRole(userRole) && (
<Tab> <TabsTrigger value="semantic-filter" className="flex-none">
Semantic Filter
</TabsTrigger>
)}
{isAdminRole(userRole) && (
<TabsTrigger value="network-settings" className="flex-none">
Network Settings
</TabsTrigger>
)}
{isAdminRole(userRole) && (
<TabsTrigger value="submitted" className="flex-none">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
Submitted MCPs <NewBadge /> Submitted MCPs <NewBadge />
</span> </span>
</Tab> </TabsTrigger>
)} )}
</div> </TabsList>
</TabList> <TabsContent value="servers">
<TabPanels>
<TabPanel>
{selectedServerId ? ( {selectedServerId ? (
<MCPServerView <MCPServerView
key={selectedServerId} key={selectedServerId}
@ -532,94 +563,117 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
<div className="w-full h-full"> <div className="w-full h-full">
<div className="w-full"> <div className="w-full">
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
<div className="flex items-center gap-6 bg-white rounded-lg px-4 py-3 border border-gray-200"> <div className="flex items-center gap-6 rounded-lg border border-border bg-card px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Text className="text-sm font-medium text-gray-600 whitespace-nowrap">Team</Text> <p className="text-sm font-medium whitespace-nowrap text-muted-foreground">Team</p>
<Select value={selectedTeam} onChange={handleTeamChange} style={{ width: 220 }} size="middle"> <Select
<Option value="all"> items={teamSelectItems}
<span className="font-medium"> value={selectedTeam}
onValueChange={(v: string | null) => handleTeamChange(v ?? "all")}
>
<SelectTrigger className="w-55">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{isInternalUser ? "All Available Servers" : "All Servers"} {isInternalUser ? "All Available Servers" : "All Servers"}
</span> </SelectItem>
</Option> <SelectItem value="personal">Personal</SelectItem>
<Option value="personal">
<span className="font-medium">Personal</span>
</Option>
{uniqueTeams.map((team) => ( {uniqueTeams.map((team) => (
<Option key={team.team_id} value={team.team_id}> <SelectItem key={team.team_id} value={team.team_id}>
<span className="font-medium">{team.team_alias || team.team_id}</span> {team.team_alias || team.team_id}
</Option> </SelectItem>
))} ))}
</SelectContent>
</Select> </Select>
</div> </div>
<div className="h-6 w-px bg-gray-200"></div> <div className="h-6 w-px bg-border" />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Text className="text-sm font-medium text-gray-600 whitespace-nowrap"> <p className="flex items-center text-sm font-medium whitespace-nowrap text-muted-foreground">
Access Group Access Group
<Tooltip title="An MCP Access Group is a set of users or teams that have permission to access specific MCP servers. Use access groups to control and organize who can connect to which servers."> <Tooltip>
<QuestionCircleOutlined style={{ marginLeft: 4, color: "#9ca3af" }} /> <TooltipTrigger
render={
<CircleHelp
className="ml-1 size-3.5 text-muted-foreground"
aria-label="About access groups"
/>
}
/>
<TooltipContent>
An MCP Access Group is a set of users or teams that have permission to access specific MCP
servers. Use access groups to control and organize who can connect to which servers.
</TooltipContent>
</Tooltip> </Tooltip>
</Text> </p>
<Select <Select
items={accessGroupSelectItems}
value={selectedMcpAccessGroup} value={selectedMcpAccessGroup}
onChange={handleMcpAccessGroupChange} onValueChange={(v: string | null) => handleMcpAccessGroupChange(v ?? "all")}
style={{ width: 220 }}
size="middle"
> >
<Option value="all"> <SelectTrigger className="w-55">
<span className="font-medium">All Access Groups</span> <SelectValue />
</Option> </SelectTrigger>
<SelectContent>
<SelectItem value="all">All Access Groups</SelectItem>
{uniqueMcpAccessGroups.map((group) => ( {uniqueMcpAccessGroups.map((group) => (
<Option key={group} value={group}> <SelectItem key={group} value={group}>
<span className="font-medium">{group}</span> {group}
</Option> </SelectItem>
))} ))}
</SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="mt-4 flex flex-wrap items-center gap-3"> <div className="mt-4 flex flex-wrap items-center gap-3">
<Input <InputGroup className="max-w-80">
allowClear <InputGroupAddon>
prefix={<SearchOutlined className="text-gray-400" />} <Search className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search by name, alias, URL, or ID" placeholder="Search by name, alias, URL, or ID"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
style={{ maxWidth: 320 }}
/> />
</InputGroup>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Text className="whitespace-nowrap text-sm font-medium text-gray-600">Sort</Text> <p className="text-sm font-medium whitespace-nowrap text-muted-foreground">Sort</p>
<Select <Select
items={SORT_OPTIONS}
value={sortKey} value={sortKey}
onChange={(v: SortKey) => setSortKey(v)} onValueChange={(v: string | null) => setSortKey((v ?? "created_desc") as SortKey)}
style={{ width: 220 }}
size="middle"
> >
<SelectTrigger className="w-55">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => ( {SORT_OPTIONS.map((opt) => (
<Option key={opt.value} value={opt.value}> <SelectItem key={opt.value} value={opt.value}>
{opt.label} {opt.label}
</Option> </SelectItem>
))} ))}
</SelectContent>
</Select> </Select>
</div> </div>
<div className="ml-auto text-xs text-gray-500"> <div className="ml-auto text-xs text-muted-foreground">
{displayedServers.length} of {filteredServers.length} servers {displayedServers.length} of {filteredServers.length} servers
</div> </div>
</div> </div>
<div className="mt-4 w-full"> <div className="mt-4 w-full">
{isLoadingServers ? ( {isLoadingServers ? (
<div className="flex items-center justify-center rounded-lg border border-dashed border-gray-200 bg-white p-12"> <div className="flex items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-card p-12">
<Spin tip="Loading MCP servers..." /> <UiLoadingSpinner className="size-6 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading MCP servers...</p>
</div> </div>
) : displayedServers.length === 0 ? ( ) : displayedServers.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-200 bg-white p-12"> <div className="rounded-lg border border-dashed border-border bg-card p-12 text-center">
<Empty <p className="text-sm text-muted-foreground">
description={ {filteredServers.length === 0
filteredServers.length === 0
? "No MCP servers configured. Click '+ Add New MCP Server' to get started." ? "No MCP servers configured. Click '+ Add New MCP Server' to get started."
: "No servers match the current filters or search." : "No servers match the current filters or search."}
} </p>
/>
</div> </div>
) : ( ) : (
<div <div
@ -650,30 +704,29 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
</div> </div>
</div> </div>
)} )}
</TabPanel> </TabsContent>
<TabPanel> <TabsContent value="toolsets">
<MCPToolsetsTab accessToken={accessToken} userRole={userRole} /> <MCPToolsetsTab accessToken={accessToken} userRole={userRole} />
</TabPanel> </TabsContent>
<TabPanel> <TabsContent value="connect">
<MCPConnect /> <MCPConnect />
</TabPanel> </TabsContent>
{isAdminRole(userRole) && ( {isAdminRole(userRole) && (
<TabPanel> <TabsContent value="semantic-filter">
<MCPSemanticFilterSettings accessToken={accessToken} /> <MCPSemanticFilterSettings accessToken={accessToken} />
</TabPanel> </TabsContent>
)} )}
{isAdminRole(userRole) && ( {isAdminRole(userRole) && (
<TabPanel> <TabsContent value="network-settings">
<MCPNetworkSettings accessToken={accessToken} /> <MCPNetworkSettings accessToken={accessToken} />
</TabPanel> </TabsContent>
)} )}
{isAdminRole(userRole) && ( {isAdminRole(userRole) && (
<TabPanel> <TabsContent value="submitted">
<MCPSubmissionsTab accessToken={accessToken} /> <MCPSubmissionsTab accessToken={accessToken} />
</TabPanel> </TabsContent>
)} )}
</TabPanels> </Tabs>
</TabGroup>
{byokModalServer && ( {byokModalServer && (
<ByokCredentialModal <ByokCredentialModal
@ -703,6 +756,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
}} }}
/> />
</div> </div>
</TooltipProvider>
); );
}; };

View file

@ -1,7 +1,14 @@
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { Card, Title, Text } from "@tremor/react"; import { Wrench, CircleCheck, Search, Pencil } from "lucide-react";
import { ToolOutlined, CheckCircleOutlined, SearchOutlined, EditOutlined } from "@ant-design/icons"; import { Badge } from "@/components/ui/badge";
import { Badge, Spin, Checkbox, Input, Radio } from "antd"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { Textarea } from "@/components/ui/textarea";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { cn } from "@/lib/cva.config";
import McpCrudPermissionPanel from "@/components/mcp_tools/McpCrudPermissionPanel"; import McpCrudPermissionPanel from "@/components/mcp_tools/McpCrudPermissionPanel";
import { TOOL_DISPLAY_NAME_PATTERN } from "./utils"; import { TOOL_DISPLAY_NAME_PATTERN } from "./utils";
@ -67,86 +74,74 @@ const ToolRow: React.FC<ToolRowProps> = ({
return ( return (
<div <div
className={`rounded-lg border transition-colors ${ className={cn(
isEnabled "rounded-lg border transition-colors",
? "bg-blue-50 border-blue-300 hover:border-blue-400" isEnabled ? "border-primary/40 bg-accent" : "border-border bg-muted",
: "bg-gray-50 border-gray-200 hover:border-gray-300" )}
}`}
> >
<div className="p-4 cursor-pointer" onClick={() => onToggle(tool.name)}> <div className="cursor-pointer p-4" onClick={() => onToggle(tool.name)}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Checkbox checked={isEnabled} onChange={() => onToggle(tool.name)} /> <Checkbox checked={isEnabled} onCheckedChange={() => onToggle(tool.name)} />
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Text className="font-medium text-gray-900">{toolNameToDisplayName[tool.name] || tool.name}</Text> <p className="text-sm font-medium">{toolNameToDisplayName[tool.name] || tool.name}</p>
<span <Badge variant={isEnabled ? "secondary" : "outline"}>{isEnabled ? "Enabled" : "Disabled"}</Badge>
className={`px-2 py-0.5 text-xs rounded-full font-medium ${ {toolNameToDisplayName[tool.name] && <Badge variant="secondary">Custom name</Badge>}
isEnabled ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
}`}
>
{isEnabled ? "Enabled" : "Disabled"}
</span>
{toolNameToDisplayName[tool.name] && (
<span className="px-2 py-0.5 text-xs rounded-full font-medium bg-purple-100 text-purple-800">
Custom name
</span>
)}
</div> </div>
{(toolNameToDescription[tool.name] || tool.description) && ( {(toolNameToDescription[tool.name] || tool.description) && (
<Text className="text-gray-500 text-sm block mt-1"> <p className="mt-1 block text-sm text-muted-foreground">
{toolNameToDescription[tool.name] || tool.description} {toolNameToDescription[tool.name] || tool.description}
</Text> </p>
)} )}
<Text className="text-gray-400 text-xs block mt-1"> <p className="mt-1 block text-xs text-muted-foreground">
{isEnabled ? "✓ Users can call this tool" : "✗ Users cannot call this tool"} {isEnabled ? "✓ Users can call this tool" : "✗ Users cannot call this tool"}
</Text> </p>
</div> </div>
<button <Button
type="button" variant="ghost"
size="icon-sm"
onClick={(e) => onToggleExpand(tool.name, e)} onClick={(e) => onToggleExpand(tool.name, e)}
className={`p-1.5 rounded-md transition-colors ${
isEditExpanded ? "bg-blue-100 text-blue-600" : "text-gray-400 hover:text-gray-600 hover:bg-gray-100"
}`}
title="Edit display name and description" title="Edit display name and description"
> >
<EditOutlined /> <Pencil />
</button> </Button>
</div> </div>
</div> </div>
{isEditExpanded && ( {isEditExpanded && (
<div <div
className="px-4 pb-4 pt-3 border-t border-gray-200 space-y-3 bg-gray-50 rounded-b-lg" className="space-y-3 rounded-b-lg border-t border-border bg-muted px-4 pt-3 pb-4"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div> <div>
<Text className="text-xs font-medium text-gray-600 mb-1 block">Display Name</Text> <p className="mb-1 block text-xs font-medium">Display Name</p>
<Input <Input
placeholder={tool.name} placeholder={tool.name}
value={toolNameToDisplayName[tool.name] || ""} value={toolNameToDisplayName[tool.name] || ""}
onChange={(e) => onDisplayNameChange(tool.name, e.target.value)} onChange={(e) => onDisplayNameChange(tool.name, e.target.value)}
status={isDisplayNameInvalid ? "error" : undefined} aria-invalid={isDisplayNameInvalid || undefined}
/> />
{isDisplayNameInvalid ? ( {isDisplayNameInvalid ? (
<Text className="text-xs text-red-500 mt-1 block"> <p className="mt-1 block text-xs text-destructive">
Only letters, digits, underscores, and hyphens are allowed (no spaces). Only letters, digits, underscores, and hyphens are allowed (no spaces).
</Text> </p>
) : ( ) : (
<Text className="text-xs text-gray-400 mt-1 block"> <p className="mt-1 block text-xs text-muted-foreground">
Override how this tool&apos;s name appears to users. Leave blank to use original. Override how this tool&apos;s name appears to users. Leave blank to use original.
</Text> </p>
)} )}
</div> </div>
<div> <div>
<Text className="text-xs font-medium text-gray-600 mb-1 block">Description</Text> <p className="mb-1 block text-xs font-medium">Description</p>
<Input.TextArea <Textarea
className="field-sizing-fixed"
placeholder={tool.description || "No description"} placeholder={tool.description || "No description"}
value={toolNameToDescription[tool.name] || ""} value={toolNameToDescription[tool.name] || ""}
onChange={(e) => onDescriptionChange(tool.name, e.target.value)} onChange={(e) => onDescriptionChange(tool.name, e.target.value)}
rows={2} rows={2}
/> />
<Text className="text-xs text-gray-400 mt-1 block"> <p className="mt-1 block text-xs text-muted-foreground">
Override the tool description shown to users. Leave blank to use original. Override the tool description shown to users. Leave blank to use original.
</Text> </p>
</div> </div>
</div> </div>
)} )}
@ -398,65 +393,62 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
} }
return ( return (
<Card> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ToolOutlined className="text-blue-600" /> <Wrench className="size-4 text-muted-foreground" />
<Title>Tool Configuration</Title> <h3 className="text-lg font-medium">Tool Configuration</h3>
{tools.length > 0 && ( {tools.length > 0 && <Badge variant="secondary">{tools.length}</Badge>}
<Badge
count={tools.length}
style={{
backgroundColor: "#52c41a",
}}
/>
)}
</div> </div>
{tools.length > 0 && ( {tools.length > 0 && (
<Radio.Group <div className="flex items-center gap-1">
value={viewMode} <Button
onChange={(e) => setViewMode(e.target.value)} size="sm"
size="small" variant={viewMode === "crud" ? "default" : "outline"}
optionType="button" onClick={() => setViewMode("crud")}
buttonStyle="solid" >
options={[ Risk Groups
{ label: "Risk Groups", value: "crud" }, </Button>
{ label: "Flat List", value: "flat" }, <Button
]} size="sm"
/> variant={viewMode === "flat" ? "default" : "outline"}
onClick={() => setViewMode("flat")}
>
Flat List
</Button>
</div>
)} )}
</div> </div>
{/* Description */} {/* Description */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3"> <div className="rounded-lg border border-border bg-muted p-3">
<Text className="text-blue-800 text-sm"> <p className="text-sm">
<strong>Select which tools users can call:</strong> Only checked tools will be available for users to <strong>Select which tools users can call:</strong> Only checked tools will be available for users to
invoke. Unchecked tools will be blocked from execution. invoke. Unchecked tools will be blocked from execution.
</Text> </p>
</div> </div>
{/* Loading state */} {/* Loading state */}
{isLoadingTools && ( {isLoadingTools && (
<div className="flex items-center justify-center py-6"> <div className="flex items-center justify-center gap-3 py-6">
<Spin size="large" /> <UiLoadingSpinner className="size-6 text-muted-foreground" />
<Text className="ml-3">Loading tools from spec...</Text> <p className="text-sm">Loading tools from spec...</p>
</div> </div>
)} )}
{/* Error state */} {/* Error state */}
{toolsError && !isLoadingTools && isPreviewForbidden && ( {toolsError && !isLoadingTools && isPreviewForbidden && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4"> <div className="rounded-lg border border-border bg-muted p-4">
<Text className="text-sm text-blue-800">{toolsError}</Text> <p className="text-sm">{toolsError}</p>
</div> </div>
)} )}
{toolsError && !isLoadingTools && !isPreviewForbidden && ( {toolsError && !isLoadingTools && !isPreviewForbidden && (
<div className="text-center py-6 text-red-500 border rounded-lg border-dashed border-red-300 bg-red-50"> <div className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 py-6 text-center">
<ToolOutlined className="text-2xl mb-2" /> <Wrench className="mx-auto mb-2 size-6 text-destructive" />
<Text className="text-red-600 font-medium">Unable to load tools</Text> <p className="text-sm font-medium text-destructive">Unable to load tools</p>
<br /> <p className="text-sm text-destructive">{toolsError}</p>
<Text className="text-sm text-red-500">{toolsError}</Text>
</div> </div>
)} )}
@ -466,51 +458,50 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
tools.length === 0 && tools.length === 0 &&
canFetchTools && canFetchTools &&
(keyTools && keyTools.length > 0 ? ( (keyTools && keyTools.length > 0 ? (
<div className="text-center py-4 text-gray-400 border rounded-lg border-dashed"> <div className="rounded-lg border border-dashed py-4 text-center text-muted-foreground">
<ToolOutlined className="text-2xl mb-2" /> <Wrench className="mx-auto mb-2 size-6" />
<Text>No tools loaded from spec</Text> <p className="text-sm">No tools loaded from spec</p>
<Text className="text-sm block mt-1">Expected tools: {keyTools.map((t) => t.name).join(", ")}</Text> <p className="mt-1 block text-sm">Expected tools: {keyTools.map((t) => t.name).join(", ")}</p>
</div> </div>
) : ( ) : (
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed"> <div className="rounded-lg border border-dashed py-6 text-center text-muted-foreground">
<ToolOutlined className="text-2xl mb-2" /> <Wrench className="mx-auto mb-2 size-6" />
<Text>No tools available for configuration</Text> <p className="text-sm">No tools available for configuration</p>
<br /> <p className="text-sm">Connect to an MCP server with tools to configure them</p>
<Text className="text-sm">Connect to an MCP server with tools to configure them</Text>
</div> </div>
))} ))}
{/* Incomplete form state */} {/* Incomplete form state */}
{!canFetchTools && (formValues.url || formValues.spec_path) && ( {!canFetchTools && (formValues.url || formValues.spec_path) && (
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed"> <div className="rounded-lg border border-dashed py-6 text-center text-muted-foreground">
<ToolOutlined className="text-2xl mb-2" /> <Wrench className="mx-auto mb-2 size-6" />
<Text>Complete required fields to configure tools</Text> <p className="text-sm">Complete required fields to configure tools</p>
<br /> <p className="text-sm">Fill in URL, Transport, and Authentication to load available tools</p>
<Text className="text-sm">Fill in URL, Transport, and Authentication to load available tools</Text>
</div> </div>
)} )}
{/* Tools loaded successfully */} {/* Tools loaded successfully */}
{!isLoadingTools && !toolsError && tools.length > 0 && ( {!isLoadingTools && !toolsError && tools.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2 p-3 bg-green-50 rounded-lg border border-green-200"> <div className="flex items-center gap-2 rounded-lg border border-border bg-muted p-3">
<CheckCircleOutlined className="text-green-600" /> <CircleCheck className="size-4" />
<Text className="text-green-700 font-medium"> <p className="text-sm font-medium">
{effectiveAllowedTools.length} of {tools.length} {tools.length === 1 ? "tool" : "tools"} enabled for {effectiveAllowedTools.length} of {tools.length} {tools.length === 1 ? "tool" : "tools"} enabled for
user access user access
</Text> </p>
</div> </div>
{/* Search box shared by both views */} {/* Search box shared by both views */}
<Input <InputGroup className="w-full">
<InputGroupAddon>
<Search className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search tools by name or description..." placeholder="Search tools by name or description..."
prefix={<SearchOutlined className="text-gray-400" />}
value={toolSearchTerm} value={toolSearchTerm}
onChange={(e) => setToolSearchTerm(e.target.value)} onChange={(e) => setToolSearchTerm(e.target.value)}
allowClear
className="rounded-lg"
size="large"
/> />
</InputGroup>
{/* CRUD grouped view */} {/* CRUD grouped view */}
{viewMode === "crud" && ( {viewMode === "crud" && (
@ -526,31 +517,25 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
{viewMode === "flat" && ( {viewMode === "flat" && (
<> <>
{filteredTools.length === 0 ? ( {filteredTools.length === 0 ? (
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed"> <div className="rounded-lg border border-dashed py-6 text-center text-muted-foreground">
<SearchOutlined className="text-2xl mb-2" /> <Search className="mx-auto mb-2 size-6" />
<Text>No tools found matching &quot;{toolSearchTerm}&quot;</Text> <p className="text-sm">No tools found matching &quot;{toolSearchTerm}&quot;</p>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{pinnedFiltered.length > 0 && ( {pinnedFiltered.length > 0 && (
<> <>
<div className="flex items-center justify-between px-1"> <div className="flex items-center justify-between px-1">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Suggested tools</p> <p className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
Suggested tools
</p>
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button variant="link" size="sm" onClick={handleEnableSuggested}>
type="button"
onClick={handleEnableSuggested}
className="text-xs text-blue-600 hover:text-blue-700"
>
Enable all Enable all
</button> </Button>
<button <Button variant="link" size="sm" onClick={handleDisableSuggested}>
type="button"
onClick={handleDisableSuggested}
className="text-xs text-gray-500 hover:text-gray-700"
>
Disable all Disable all
</button> </Button>
</div> </div>
</div> </div>
{pinnedFiltered.map((tool) => ( {pinnedFiltered.map((tool) => (
@ -572,24 +557,16 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
{restFiltered.length > 0 && ( {restFiltered.length > 0 && (
<> <>
<div className="flex items-center justify-between px-1 pt-2"> <div className="flex items-center justify-between px-1 pt-2">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide"> <p className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
{pinnedFiltered.length > 0 ? "All tools" : "Tools"} {pinnedFiltered.length > 0 ? "All tools" : "Tools"}
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button variant="link" size="sm" onClick={handleEnableRest}>
type="button"
onClick={handleEnableRest}
className="text-xs text-blue-600 hover:text-blue-700"
>
Enable all Enable all
</button> </Button>
<button <Button variant="link" size="sm" onClick={handleDisableRest}>
type="button"
onClick={handleDisableRest}
className="text-xs text-gray-500 hover:text-gray-700"
>
Disable all Disable all
</button> </Button>
</div> </div>
</div> </div>
{restFiltered.map((tool) => ( {restFiltered.map((tool) => (

View file

@ -19,9 +19,13 @@ import { useUserMcpOAuthFlow } from "@/hooks/useUserMcpOAuthFlow";
import { TOOLS_OAUTH_UI_STATE_KEY } from "@/hooks/mcpOAuthUtils"; import { TOOLS_OAUTH_UI_STATE_KEY } from "@/hooks/mcpOAuthUtils";
import { setSecureItem } from "@/utils/secureStorage"; import { setSecureItem } from "@/utils/secureStorage";
import { Card, Title, Text } from "@tremor/react"; import { Bot, Wrench, Search, Key, Lock } from "lucide-react";
import { RobotOutlined, ToolOutlined, SearchOutlined, KeyOutlined, LockOutlined } from "@ant-design/icons"; import { Badge } from "@/components/ui/badge";
import { Input, Button as AntdButton } from "antd"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { cn } from "@/lib/cva.config";
const MCPToolsViewer = ({ const MCPToolsViewer = ({
serverId, serverId,
@ -285,45 +289,43 @@ const MCPToolsViewer = ({
}); });
return ( return (
<div className="w-full h-screen p-4 bg-white"> <div className="w-full p-4">
<Card className="w-full rounded-xl shadow-md overflow-hidden"> <Card className="w-full overflow-hidden rounded-xl shadow-md">
<div className="flex h-auto w-full gap-4"> <div className="grid h-auto w-full grid-cols-4 gap-4">
{/* Left Sidebar with Controls */} {/* Left Sidebar with Controls */}
<div className="w-1/4 p-4 bg-gray-50 flex flex-col"> <div className="col-span-1 flex flex-col bg-muted p-4">
<Title className="text-xl font-semibold mb-6 mt-2">MCP Tools</Title> <h2 className="mt-2 mb-6 text-xl font-semibold">MCP Tools</h2>
<div className="flex flex-col flex-1"> <div className="flex flex-col flex-1">
{/* Extra Headers Input Section */} {/* Extra Headers Input Section */}
{hasExtraHeaders && ( {hasExtraHeaders && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg"> <div className="mb-4 rounded-lg border border-border bg-card p-3">
<div className="flex items-center justify-between mb-2"> <div className="mb-2 flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center">
<KeyOutlined className="text-blue-600 mr-2" /> <Key className="mr-2 size-4 text-muted-foreground" />
<Text className="text-sm font-medium text-blue-800">Additional Headers</Text> <p className="text-sm font-medium">Additional Headers</p>
</div> </div>
<AntdButton <Button variant="link" size="sm" onClick={() => setShowHeaderInput(!showHeaderInput)}>
size="small"
type="link"
onClick={() => setShowHeaderInput(!showHeaderInput)}
className="text-blue-700 p-0 h-auto"
>
{showHeaderInput ? "Hide" : "Configure"} {showHeaderInput ? "Hide" : "Configure"}
</AntdButton> </Button>
</div> </div>
{!showHeaderInput && Object.keys(passthroughHeaders).length === 0 && ( {!showHeaderInput && Object.keys(passthroughHeaders).length === 0 && (
<Text className="text-xs text-blue-700"> <p className="text-xs text-muted-foreground">
This server requires additional headers. Click &quot;Configure&quot; to provide values. This server requires additional headers. Click &quot;Configure&quot; to provide values.
</Text> </p>
)} )}
{showHeaderInput && ( {showHeaderInput && (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{extraHeaders?.map((headerName) => ( {extraHeaders?.map((headerName) => (
<div key={headerName}> <div key={headerName}>
<label className="block text-xs font-medium text-gray-700 mb-1">{headerName}</label> <label className="mb-1 block text-xs font-medium">{headerName}</label>
<Input <InputGroup className="w-full">
size="small" <InputGroupAddon>
<Key className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder={`Enter ${headerName}`} placeholder={`Enter ${headerName}`}
value={passthroughHeaders[headerName] || ""} value={passthroughHeaders[headerName] || ""}
onChange={(e) => { onChange={(e) => {
@ -332,32 +334,30 @@ const MCPToolsViewer = ({
[headerName]: e.target.value, [headerName]: e.target.value,
}); });
}} }}
prefix={<KeyOutlined className="text-gray-400" />}
className="rounded-sm"
/> />
</InputGroup>
</div> </div>
))} ))}
<AntdButton <Button
size="small" size="sm"
type="primary"
onClick={() => { onClick={() => {
refetchTools(); refetchTools();
setShowHeaderInput(false); setShowHeaderInput(false);
}} }}
disabled={Object.values(passthroughHeaders).every((v) => !v || !v.trim())} disabled={Object.values(passthroughHeaders).every((v) => !v || !v.trim())}
className="w-full mt-2" className="mt-2 w-full"
> >
Load Tools Load Tools
</AntdButton> </Button>
</div> </div>
)} )}
{!showHeaderInput && Object.keys(passthroughHeaders).length > 0 && ( {!showHeaderInput && Object.keys(passthroughHeaders).length > 0 && (
<div className="mt-2"> <div className="mt-2">
<Text className="text-xs text-green-700 flex items-center"> <p className="flex items-center text-xs text-muted-foreground">
<span className="inline-block w-2 h-2 bg-green-500 rounded-full mr-2"></span> <span className="mr-2 inline-block size-2 rounded-full bg-green-500" />
{Object.keys(passthroughHeaders).length} header(s) configured {Object.keys(passthroughHeaders).length} header(s) configured
</Text> </p>
</div> </div>
)} )}
</div> </div>
@ -365,31 +365,29 @@ const MCPToolsViewer = ({
{/* Tool Selection - Show tools first */} {/* Tool Selection - Show tools first */}
<div className="flex flex-col flex-1 min-h-0"> <div className="flex flex-col flex-1 min-h-0">
<Text className="font-medium block mb-3 text-gray-700 flex items-center"> <p className="mb-3 flex items-center text-sm font-medium">
<ToolOutlined className="mr-2" /> Available Tools <Wrench className="mr-2 size-4" /> Available Tools
{toolsData.length > 0 && ( {toolsData.length > 0 && (
<span className="ml-2 bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded-full"> <Badge variant="secondary" className="ml-2">
{toolsData.length} {toolsData.length}
</span> </Badge>
)} )}
</Text> </p>
{/* Passthrough auth gate — browser session token absent */} {/* Passthrough auth gate — browser session token absent */}
{usesBrowserHeldToken && !oauthToken && ( {usesBrowserHeldToken && !oauthToken && (
<div className="p-4 text-center bg-white border border-gray-200 rounded-lg"> <div className="rounded-lg border border-border bg-card p-4 text-center">
<LockOutlined className="text-2xl text-gray-400 mb-2" /> <Lock className="mx-auto mb-2 size-6 text-muted-foreground" />
<p className="text-xs font-medium text-gray-700 mb-1">Authentication required</p> <p className="mb-1 text-xs font-medium">Authentication required</p>
<p className="text-xs text-gray-500 mb-3">Authenticate to view available tools</p> <p className="mb-3 text-xs text-muted-foreground">Authenticate to view available tools</p>
<AntdButton <Button
size="small" size="sm"
type="primary"
loading={oauthStatus === "authorizing" || oauthStatus === "exchanging"}
onClick={startOAuthFlow} onClick={startOAuthFlow}
disabled={!accessToken} disabled={!accessToken || oauthStatus === "authorizing" || oauthStatus === "exchanging"}
> >
Authorize Authorize
</AntdButton> </Button>
{oauthError && <p className="text-xs text-red-500 mt-2">{oauthError}</p>} {oauthError && <p className="mt-2 text-xs text-destructive">{oauthError}</p>}
</div> </div>
)} )}
@ -399,22 +397,20 @@ const MCPToolsViewer = ({
with no usable refresh token). A refreshable token is refreshed with no usable refresh token). A refreshable token is refreshed
on the list call and never trips this gate. */} on the list call and never trips this gate. */}
{(authorizationCodeNeedsAuth || authorizationCodeTokenRejected) && ( {(authorizationCodeNeedsAuth || authorizationCodeTokenRejected) && (
<div className="p-4 text-center bg-white border border-gray-200 rounded-lg"> <div className="rounded-lg border border-border bg-card p-4 text-center">
<LockOutlined className="text-2xl text-gray-400 mb-2" /> <Lock className="mx-auto mb-2 size-6 text-muted-foreground" />
<p className="text-xs font-medium text-gray-700 mb-1">Authentication required</p> <p className="mb-1 text-xs font-medium">Authentication required</p>
<p className="text-xs text-gray-500 mb-3"> <p className="mb-3 text-xs text-muted-foreground">
Authenticate with the upstream provider to view available tools Authenticate with the upstream provider to view available tools
</p> </p>
<AntdButton <Button
size="small" size="sm"
type="primary"
loading={dbOAuthStatus === "authorizing" || dbOAuthStatus === "exchanging"}
onClick={startAuthorizationCodeAuthorize} onClick={startAuthorizationCodeAuthorize}
disabled={!accessToken} disabled={!accessToken || dbOAuthStatus === "authorizing" || dbOAuthStatus === "exchanging"}
> >
Authorize Authorize
</AntdButton> </Button>
{dbOAuthError && <p className="text-xs text-red-500 mt-2">{dbOAuthError}</p>} {dbOAuthError && <p className="mt-2 text-xs text-destructive">{dbOAuthError}</p>}
</div> </div>
)} )}
@ -423,32 +419,30 @@ const MCPToolsViewer = ({
<> <>
{toolsData.length > 0 && ( {toolsData.length > 0 && (
<div className="mb-3"> <div className="mb-3">
<Input <InputGroup className="w-full">
<InputGroupAddon>
<Search className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search tools..." placeholder="Search tools..."
prefix={<SearchOutlined className="text-gray-400" />}
value={toolSearchTerm} value={toolSearchTerm}
onChange={(e) => setToolSearchTerm(e.target.value)} onChange={(e) => setToolSearchTerm(e.target.value)}
allowClear
className="rounded-lg"
size="middle"
/> />
</InputGroup>
</div> </div>
)} )}
{/* Loading State */} {/* Loading State */}
{toolsAreaLoading && ( {toolsAreaLoading && (
<div className="flex flex-col items-center justify-center py-8 bg-white border border-gray-200 rounded-lg"> <div className="flex flex-col items-center justify-center rounded-lg border border-border bg-card py-8">
<div className="relative mb-3"> <UiLoadingSpinner className="mb-3 size-6 text-muted-foreground" />
<div className="animate-spin rounded-full h-6 w-6 border-2 border-gray-200"></div> <p className="text-xs font-medium">Loading tools...</p>
<div className="animate-spin rounded-full h-6 w-6 border-2 border-blue-600 border-t-transparent absolute top-0"></div>
</div>
<p className="text-xs font-medium text-gray-700">Loading tools...</p>
</div> </div>
)} )}
{/* Error State */} {/* Error State */}
{(mcpToolsResponse?.error || mcpToolsError) && !toolsAreaLoading && !toolsData.length && ( {(mcpToolsResponse?.error || mcpToolsError) && !toolsAreaLoading && !toolsData.length && (
<div className="p-3 text-xs text-red-800 rounded-lg bg-red-50 border border-red-200"> <div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-xs text-destructive">
<p className="font-medium"> <p className="font-medium">
Error: {mcpToolsResponse?.message || (mcpToolsError as Error)?.message} Error: {mcpToolsResponse?.message || (mcpToolsError as Error)?.message}
</p> </p>
@ -460,10 +454,10 @@ const MCPToolsViewer = ({
!mcpToolsResponse?.error && !mcpToolsResponse?.error &&
!mcpToolsError && !mcpToolsError &&
(!toolsData || toolsData.length === 0) && ( (!toolsData || toolsData.length === 0) && (
<div className="p-4 text-center bg-white border border-gray-200 rounded-lg"> <div className="rounded-lg border border-border bg-card p-4 text-center">
<div className="mx-auto w-8 h-8 bg-gray-200 rounded-full flex items-center justify-center mb-2"> <div className="mx-auto mb-2 flex size-8 items-center justify-center rounded-full bg-muted">
<svg <svg
className="w-4 h-4 text-gray-400" className="size-4 text-muted-foreground"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -476,8 +470,8 @@ const MCPToolsViewer = ({
/> />
</svg> </svg>
</div> </div>
<p className="text-xs font-medium text-gray-700 mb-1">No tools available</p> <p className="mb-1 text-xs font-medium">No tools available</p>
<p className="text-xs text-gray-500">No tools found for this server</p> <p className="text-xs text-muted-foreground">No tools found for this server</p>
</div> </div>
)} )}
@ -485,28 +479,22 @@ const MCPToolsViewer = ({
{!toolsAreaLoading && !mcpToolsResponse?.error && toolsData.length > 0 && ( {!toolsAreaLoading && !mcpToolsResponse?.error && toolsData.length > 0 && (
<> <>
{filteredTools.length === 0 ? ( {filteredTools.length === 0 ? (
<div className="p-4 text-center bg-white border border-gray-200 rounded-lg"> <div className="rounded-lg border border-border bg-card p-4 text-center">
<SearchOutlined className="text-2xl text-gray-400 mb-2" /> <Search className="mx-auto mb-2 size-6 text-muted-foreground" />
<p className="text-xs font-medium text-gray-700 mb-1">No tools found</p> <p className="mb-1 text-xs font-medium">No tools found</p>
<p className="text-xs text-gray-500">No tools match &quot;{toolSearchTerm}&quot;</p> <p className="text-xs text-muted-foreground">No tools match &quot;{toolSearchTerm}&quot;</p>
</div> </div>
) : ( ) : (
<div <div className="mcp-tools-scrollable max-h-100 min-h-0 flex-1 space-y-2 overflow-y-auto">
className="space-y-2 flex-1 overflow-y-auto min-h-0 mcp-tools-scrollable"
style={{
maxHeight: "400px",
scrollbarWidth: "auto",
scrollbarColor: "#cbd5e0 #f7fafc",
}}
>
{filteredTools.map((tool: MCPTool) => ( {filteredTools.map((tool: MCPTool) => (
<div <div
key={tool.name} key={tool.name}
className={`border rounded-lg p-3 cursor-pointer transition-all hover:shadow-xs ${ className={cn(
"cursor-pointer rounded-lg border p-3 transition-all hover:shadow-xs",
selectedTool?.name === tool.name selectedTool?.name === tool.name
? "border-blue-500 bg-blue-50 ring-1 ring-blue-200" ? "border-primary bg-accent ring-1 ring-ring"
: "border-gray-200 bg-white hover:border-gray-300" : "border-border bg-card",
}`} )}
onClick={() => { onClick={() => {
setSelectedTool(tool); setSelectedTool(tool);
setToolResult(null); setToolResult(null);
@ -522,18 +510,18 @@ const MCPToolsViewer = ({
/> />
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h4 className="font-mono text-xs font-medium text-gray-900 truncate"> <h4 className="truncate font-mono text-xs font-medium">{tool.name}</h4>
{tool.name} <p className="truncate text-xs text-muted-foreground">
</h4> {tool.mcp_info.server_name}
<p className="text-xs text-gray-500 truncate">{tool.mcp_info.server_name}</p> </p>
<p className="text-xs text-gray-600 mt-1 line-clamp-2 leading-relaxed"> <p className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
{tool.description} {tool.description}
</p> </p>
</div> </div>
</div> </div>
{selectedTool?.name === tool.name && ( {selectedTool?.name === tool.name && (
<div className="mt-2 pt-2 border-t border-blue-200"> <div className="mt-2 border-t border-border pt-2">
<div className="flex items-center text-xs font-medium text-blue-700"> <div className="flex items-center text-xs font-medium text-primary">
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path <path
fillRule="evenodd" fillRule="evenodd"
@ -558,20 +546,20 @@ const MCPToolsViewer = ({
</div> </div>
{/* Main Testing Area */} {/* Main Testing Area */}
<div className="w-3/4 flex flex-col bg-white"> <div className="col-span-3 flex flex-col">
<div className="p-4 border-b border-gray-200 flex justify-between items-center"> <div className="flex items-center justify-between border-b border-border p-4">
<Title className="text-xl font-semibold mb-0">Tool Testing Playground</Title> <h2 className="mb-0 text-xl font-semibold">Tool Testing Playground</h2>
</div> </div>
<div className="flex-1 overflow-auto p-4"> <div className="flex-1 overflow-auto p-4">
{!selectedTool ? ( {!selectedTool ? (
/* Empty State */ /* Empty State */
<div className="h-full flex flex-col items-center justify-center text-gray-400"> <div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<RobotOutlined style={{ fontSize: "48px", marginBottom: "16px" }} /> <Bot className="mb-4 size-12" />
<Text className="text-lg font-medium text-gray-600 mb-2">Select a Tool to Test</Text> <p className="mb-2 text-lg font-medium">Select a Tool to Test</p>
<Text className="text-center text-gray-500 max-w-md"> <p className="max-w-md text-center text-sm">
Choose a tool from the left sidebar to start testing its functionality with custom inputs. Choose a tool from the left sidebar to start testing its functionality with custom inputs.
</Text> </p>
</div> </div>
) : ( ) : (
/* Tool Test Panel */ /* Tool Test Panel */

View file

@ -0,0 +1,88 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { MemoryRow } from "@/components/networking";
import { MemoryDetailDrawer } from "./MemoryDetailDrawer";
const makeMemory = (overrides: Partial<MemoryRow> = {}): MemoryRow => ({
memory_id: "mem-1",
key: "user:profile",
value: "The user prefers concise answers.",
metadata: null,
user_id: "user-42",
team_id: "team-7",
created_at: "2024-05-01T12:00:00Z",
updated_at: "2024-05-02T12:00:00Z",
created_by: "alice",
updated_by: "bob",
...overrides,
});
describe("MemoryDetailDrawer", () => {
it("renders nothing until a row is selected", () => {
render(<MemoryDetailDrawer row={null} onClose={vi.fn()} />);
expect(screen.queryByText("Memory ID")).not.toBeInTheDocument();
expect(screen.queryByText("Value")).not.toBeInTheDocument();
});
it("shows the selected row's key, identifiers and value", () => {
render(<MemoryDetailDrawer row={makeMemory()} onClose={vi.fn()} />);
expect(screen.getByText("user:profile")).toBeInTheDocument();
expect(screen.getByText("Memory ID")).toBeInTheDocument();
expect(screen.getByText("mem-1")).toBeInTheDocument();
expect(screen.getByText("User ID")).toBeInTheDocument();
expect(screen.getByText("user-42")).toBeInTheDocument();
expect(screen.getByText("Team ID")).toBeInTheDocument();
expect(screen.getByText("team-7")).toBeInTheDocument();
expect(screen.getByText("Value")).toBeInTheDocument();
expect(screen.getByText("The user prefers concise answers.")).toBeInTheDocument();
});
it("falls back to a dash for a memory with no owning user or team", () => {
render(<MemoryDetailDrawer row={makeMemory({ user_id: null, team_id: null })} onClose={vi.fn()} />);
expect(screen.getAllByText("-")).toHaveLength(2);
expect(screen.queryByText("user-42")).not.toBeInTheDocument();
});
it("omits the metadata block when the row carries no metadata", () => {
render(<MemoryDetailDrawer row={makeMemory({ metadata: null })} onClose={vi.fn()} />);
expect(screen.queryByText("Metadata")).not.toBeInTheDocument();
});
it("pretty-prints metadata as JSON when present", () => {
render(<MemoryDetailDrawer row={makeMemory({ metadata: { tags: ["example"] } })} onClose={vi.fn()} />);
expect(screen.getByText("Metadata")).toBeInTheDocument();
expect(screen.getByText('{ "tags": [ "example" ] }')).toBeInTheDocument();
});
it("attributes the created and updated timestamps to their actors", () => {
render(<MemoryDetailDrawer row={makeMemory()} onClose={vi.fn()} />);
expect(screen.getByText(/^Created .* by alice$/)).toBeInTheDocument();
expect(screen.getByText(/^Updated .* by bob$/)).toBeInTheDocument();
});
it("renders an em dash for a timestamp the backend did not send", () => {
render(<MemoryDetailDrawer row={makeMemory({ created_at: undefined, created_by: undefined })} onClose={vi.fn()} />);
expect(screen.getByText("Created —")).toBeInTheDocument();
});
it("closes through the close control", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<MemoryDetailDrawer row={makeMemory()} onClose={onClose} />);
await user.click(screen.getByRole("button", { name: /close/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,17 +1,19 @@
"use client"; "use client";
import { Drawer, Space, Typography } from "antd";
import React from "react"; import React from "react";
import { MemoryRow } from "@/components/networking"; import { MemoryRow } from "@/components/networking";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
const { Text, Paragraph } = Typography;
interface MemoryDetailDrawerProps { interface MemoryDetailDrawerProps {
row: MemoryRow | null; row: MemoryRow | null;
onClose: () => void; onClose: () => void;
} }
const CODE_CLASS = "rounded-sm border border-border bg-muted px-1 py-0.5 font-mono text-xs text-foreground";
const BLOCK_CLASS = "mt-1 rounded-md bg-muted p-3 font-mono whitespace-pre-wrap text-foreground";
const LABEL_CLASS = "text-sm font-semibold text-foreground";
function formatTimestamp(ts?: string): string { function formatTimestamp(ts?: string): string {
if (!ts) return "—"; if (!ts) return "—";
try { try {
@ -24,90 +26,61 @@ function formatTimestamp(ts?: string): string {
export function MemoryDetailDrawer({ row, onClose }: MemoryDetailDrawerProps) { export function MemoryDetailDrawer({ row, onClose }: MemoryDetailDrawerProps) {
return ( return (
<Drawer <Sheet
open={!!row} open={!!row}
onClose={onClose} onOpenChange={(open) => {
title={ if (!open) onClose();
row ? (
<Space>
<Text code>{row.key}</Text>
</Space>
) : (
"Memory"
)
}
width={720}
destroyOnClose
>
{row && (
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Space size="large" wrap>
<div>
<Text strong style={{ display: "block" }}>
Memory ID
</Text>
<Text code style={{ fontSize: 12 }}>
{row.memory_id}
</Text>
</div>
<div>
<Text strong style={{ display: "block" }}>
User ID
</Text>
<Text type={row.user_id ? undefined : "secondary"}>{row.user_id ?? "-"}</Text>
</div>
<div>
<Text strong style={{ display: "block" }}>
Team ID
</Text>
<Text type={row.team_id ? undefined : "secondary"}>{row.team_id ?? "-"}</Text>
</div>
</Space>
<div>
<Text strong>Value</Text>
<Paragraph
style={{
background: "#fafafa",
padding: 12,
borderRadius: 6,
whiteSpace: "pre-wrap",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: 13,
}} }}
> >
{row.value} <SheetContent className="overflow-y-auto data-[side=right]:w-full data-[side=right]:max-w-full data-[side=right]:sm:w-[720px] data-[side=right]:sm:max-w-full">
</Paragraph> <SheetHeader className="border-b">
<SheetTitle>{row ? <code className={CODE_CLASS}>{row.key}</code> : "Memory"}</SheetTitle>
</SheetHeader>
{row && (
<div className="flex flex-col gap-4 px-4 pb-4">
<div className="flex flex-wrap gap-x-8 gap-y-3">
<div>
<span className={`block ${LABEL_CLASS}`}>Memory ID</span>
<code className={CODE_CLASS}>{row.memory_id}</code>
</div>
<div>
<span className={`block ${LABEL_CLASS}`}>User ID</span>
<span className={row.user_id ? "text-sm text-foreground" : "text-sm text-muted-foreground"}>
{row.user_id ?? "-"}
</span>
</div>
<div>
<span className={`block ${LABEL_CLASS}`}>Team ID</span>
<span className={row.team_id ? "text-sm text-foreground" : "text-sm text-muted-foreground"}>
{row.team_id ?? "-"}
</span>
</div>
</div>
<div>
<span className={LABEL_CLASS}>Value</span>
<p className={`${BLOCK_CLASS} text-[13px]`}>{row.value}</p>
</div> </div>
{row.metadata !== undefined && row.metadata !== null && ( {row.metadata !== undefined && row.metadata !== null && (
<div> <div>
<Text strong>Metadata</Text> <span className={LABEL_CLASS}>Metadata</span>
<Paragraph <p className={`${BLOCK_CLASS} text-xs`}>{JSON.stringify(row.metadata, null, 2)}</p>
style={{
background: "#fafafa",
padding: 12,
borderRadius: 6,
whiteSpace: "pre-wrap",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: 12,
}}
>
{JSON.stringify(row.metadata, null, 2)}
</Paragraph>
</div> </div>
)} )}
<Space split={<Text type="secondary">·</Text>} wrap size="small" style={{ color: "rgba(0,0,0,0.45)" }}> <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Text type="secondary"> <span>
Created {formatTimestamp(row.created_at)} Created {formatTimestamp(row.created_at)}
{row.created_by ? ` by ${row.created_by}` : ""} {row.created_by ? ` by ${row.created_by}` : ""}
</Text> </span>
<Text type="secondary"> <span aria-hidden="true">·</span>
<span>
Updated {formatTimestamp(row.updated_at)} Updated {formatTimestamp(row.updated_at)}
{row.updated_by ? ` by ${row.updated_by}` : ""} {row.updated_by ? ` by ${row.updated_by}` : ""}
</Text> </span>
</Space> </div>
</Space> </div>
)} )}
</Drawer> </SheetContent>
</Sheet>
); );
} }

View file

@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react"; import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react"; import React from "react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
@ -12,6 +13,7 @@ interface CapturedTableProps {
rowCount: number; rowCount: number;
data: MemoryRow[]; data: MemoryRow[];
hasActiveSearch: boolean; hasActiveSearch: boolean;
onViewClick: (row: MemoryRow) => void;
} }
const captured = vi.hoisted(() => ({ current: null as CapturedTableProps | null })); const captured = vi.hoisted(() => ({ current: null as CapturedTableProps | null }));
@ -42,4 +44,47 @@ describe("MemoryView", () => {
expect(captured.current?.rowCount).toBe(0); expect(captured.current?.rowCount).toBe(0);
expect(captured.current?.hasActiveSearch).toBe(false); expect(captured.current?.hasActiveSearch).toBe(false);
}); });
it("heads the page with the Memory title and the /v1/memory scope note", () => {
renderView(null);
expect(screen.getByRole("heading", { name: "Memory" })).toBeInTheDocument();
expect(screen.getByText("/v1/memory")).toBeInTheDocument();
expect(screen.getByText(/Scoped to memories visible to your user \/ team \(admins see all\)/)).toBeInTheDocument();
});
it("opens the create modal from the New memory button", async () => {
const user = userEvent.setup();
renderView(null);
expect(screen.queryByText("Create memory")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /new memory/i }));
expect(await screen.findByText("Create memory")).toBeInTheDocument();
});
it("opens the detail drawer for the row the table hands back, and closes it again", async () => {
const user = userEvent.setup();
renderView(null);
expect(screen.queryByText("Memory ID")).not.toBeInTheDocument();
const row: MemoryRow = {
memory_id: "mem-drawer",
key: "user:profile",
value: "remembered",
metadata: null,
user_id: null,
team_id: null,
};
act(() => captured.current?.onViewClick(row));
expect(await screen.findByText("Memory ID")).toBeInTheDocument();
expect(screen.getByText("mem-drawer")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /close/i }));
expect(screen.queryByText("mem-drawer")).not.toBeInTheDocument();
});
}); });

View file

@ -3,20 +3,19 @@
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { PaginationState } from "@tanstack/react-table"; import type { PaginationState } from "@tanstack/react-table";
import { PlusOutlined } from "@ant-design/icons"; import { Plus } from "lucide-react";
import { Button, Space, Typography, message } from "antd";
import React, { useCallback, useMemo, useState } from "react"; import React, { useCallback, useMemo, useState } from "react";
import { MemoryRow, createMemory, deleteMemory, fetchMemoryList, updateMemory } from "@/components/networking"; import { MemoryRow, createMemory, deleteMemory, fetchMemoryList, updateMemory } from "@/components/networking";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import MessageManager from "@/components/molecules/message_manager";
import { Button } from "@/components/ui/button";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { MemoryDetailDrawer } from "./MemoryDetailDrawer"; import { MemoryDetailDrawer } from "./MemoryDetailDrawer";
import { MemoryEditModal } from "./MemoryEditModal"; import { MemoryEditModal } from "./MemoryEditModal";
import { MemoryTable } from "./MemoryTable"; import { MemoryTable } from "./MemoryTable";
const { Text, Paragraph, Title } = Typography;
interface MemoryViewProps { interface MemoryViewProps {
accessToken: string | null; accessToken: string | null;
userID: string | null; userID: string | null;
@ -62,7 +61,7 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
// All three write endpoints share the same success/error plumbing: // All three write endpoints share the same success/error plumbing:
// - on success: invalidate the list query so every cached page // - on success: invalidate the list query so every cached page
// refetches from scratch (pagination + filter-aware). // refetches from scratch (pagination + filter-aware).
// - on error: surface the message via antd `message.error`. // - on error: surface the message via `MessageManager.error`.
const invalidateList = useCallback( const invalidateList = useCallback(
() => queryClient.invalidateQueries({ queryKey: [MEMORY_LIST_KEY] }), () => queryClient.invalidateQueries({ queryKey: [MEMORY_LIST_KEY] }),
@ -75,11 +74,11 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
return createMemory(accessToken, args); return createMemory(accessToken, args);
}, },
onSuccess: (row) => { onSuccess: (row) => {
message.success(`Created ${row.key}`); MessageManager.success(`Created ${row.key}`);
invalidateList(); invalidateList();
}, },
onError: (err: Error) => { onError: (err: Error) => {
message.error(`Save failed: ${err.message}`); MessageManager.error(`Save failed: ${err.message}`);
}, },
}); });
@ -90,11 +89,11 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
return updateMemory(accessToken, key, payload); return updateMemory(accessToken, key, payload);
}, },
onSuccess: (row) => { onSuccess: (row) => {
message.success(`Updated ${row.key}`); MessageManager.success(`Updated ${row.key}`);
invalidateList(); invalidateList();
}, },
onError: (err: Error) => { onError: (err: Error) => {
message.error(`Save failed: ${err.message}`); MessageManager.error(`Save failed: ${err.message}`);
}, },
}); });
@ -104,11 +103,11 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
return deleteMemory(accessToken, key).then(() => key); return deleteMemory(accessToken, key).then(() => key);
}, },
onSuccess: (key) => { onSuccess: (key) => {
message.success(`Deleted ${key}`); MessageManager.success(`Deleted ${key}`);
invalidateList(); invalidateList();
}, },
onError: (err: Error) => { onError: (err: Error) => {
message.error(`Delete failed: ${err.message}`); MessageManager.error(`Delete failed: ${err.message}`);
}, },
}); });
@ -150,7 +149,7 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
try { try {
metadataPayload = JSON.parse(metadataText); metadataPayload = JSON.parse(metadataText);
} catch { } catch {
message.error("Metadata must be valid JSON (or leave empty)."); MessageManager.error("Metadata must be valid JSON (or leave empty).");
return false; return false;
} }
} }
@ -177,19 +176,21 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
}; };
return ( return (
<div className="w-full" style={{ padding: 24 }}> <div className="w-full p-6">
<Space direction="vertical" size="large" style={{ width: "100%" }}> <div className="flex flex-col gap-6">
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 16 }}> <div className="flex items-start justify-between gap-4">
<div> <div>
<Title level={3} style={{ marginBottom: 4 }}> <h1 className="text-2xl font-semibold text-foreground">Memory</h1>
Memory <p className="mt-1 text-sm text-muted-foreground">
</Title> Inspect what your agents have stored under{" "}
<Paragraph type="secondary" style={{ marginBottom: 0 }}> <code className="rounded-sm border border-border bg-muted px-1 py-0.5 font-mono text-xs text-foreground">
Inspect what your agents have stored under <Text code>/v1/memory</Text>. Scoped to memories visible to /v1/memory
your user / team (admins see all). </code>
</Paragraph> . Scoped to memories visible to your user / team (admins see all).
</p>
</div> </div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setIsCreateOpen(true)}> <Button onClick={() => setIsCreateOpen(true)}>
<Plus />
New memory New memory
</Button> </Button>
</div> </div>
@ -209,7 +210,7 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
onEditClick={handleEdit} onEditClick={handleEdit}
onDeleteClick={handleDelete} onDeleteClick={handleDelete}
/> />
</Space> </div>
{/* Detail drawer */} {/* Detail drawer */}
<MemoryDetailDrawer row={detailRow} onClose={() => setDetailRow(null)} /> <MemoryDetailDrawer row={detailRow} onClose={() => setDetailRow(null)} />

View file

@ -106,7 +106,7 @@ describe("OrganizationFilters", () => {
org_alias: "test org", org_alias: "test org",
}; };
render( const { container } = render(
<OrganizationFilters <OrganizationFilters
filters={filtersWithActive} filters={filtersWithActive}
showFilters={false} showFilters={false}
@ -116,8 +116,7 @@ describe("OrganizationFilters", () => {
/>, />,
); );
const filtersButton = screen.getByRole("button", { name: /^filters$/i }); expect(screen.getByRole("button", { name: /^filters$/i })).toBeInTheDocument();
const badgeWrapper = filtersButton.closest(".ant-badge"); expect(container.querySelector("sup")).toBeInTheDocument();
expect(badgeWrapper).toBeInTheDocument();
}); });
}); });

View file

@ -0,0 +1,127 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import CreateKeyPage from "./page";
interface KeyRow {
token: string;
}
const { mockReplace, mockUseKeys, mockMigratedHref, state } = vi.hoisted(() => {
const state = {
login: "success" as string | null,
userRole: "Internal User",
keys: [] as KeyRow[],
keysLoading: false,
returnUrl: null as string | null,
};
return {
state,
mockReplace: vi.fn(),
mockMigratedHref: vi.fn((segment: string) => `/mocked-ui/${segment}`),
mockUseKeys: vi.fn((_page: number, _size: number, _opts: unknown, _enabled: boolean) => ({
data: state.keysLoading ? undefined : { keys: state.keys, total_count: state.keys.length },
isLoading: state.keysLoading,
})),
};
});
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: mockReplace }),
useSearchParams: () => ({ get: (key: string) => (key === "login" ? state.login : null) }),
}));
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({
authLoading: false,
token: "tok",
userRole: state.userRole,
userID: "user-1",
}),
}));
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ useKeys: mockUseKeys }));
vi.mock("@/app/(dashboard)/api-keys/ApiKeysDashboard", () => ({
default: () => <div data-testid="api-keys-dashboard" />,
}));
vi.mock("@/components/common_components/LoadingScreen", () => ({
default: () => <div data-testid="loading-screen" />,
}));
vi.mock("@/components/networking", () => ({ proxyBaseUrl: "" }));
vi.mock("@/utils/migratedPages", () => ({ MIGRATED_PAGES: {}, migratedHref: mockMigratedHref }));
vi.mock("@/utils/returnUrlUtils", () => ({
buildLoginUrlWithReturn: (u: string) => u,
consumeReturnUrl: () => state.returnUrl,
getLoginUrl: () => "/login",
isValidReturnUrl: () => true,
normalizeUrlForCompare: (u: string) => u,
storeReturnUrl: () => undefined,
}));
describe("dashboard landing keyless redirect", () => {
afterEach(() => {
state.login = "success";
state.userRole = "Internal User";
state.keys = [];
state.keysLoading = false;
state.returnUrl = null;
mockReplace.mockClear();
mockUseKeys.mockClear();
mockMigratedHref.mockClear();
});
it.each(["Internal User", "Internal Viewer"])("sends a keyless %s to the connect page after login", (role) => {
state.userRole = role;
render(<CreateKeyPage />);
expect(mockReplace).toHaveBeenCalledWith("/mocked-ui/connect");
expect(screen.queryByTestId("api-keys-dashboard")).not.toBeInTheDocument();
});
it.each(["Admin", "Admin Viewer", "Org Admin"])("leaves a keyless %s on the dashboard", (role) => {
state.userRole = role;
render(<CreateKeyPage />);
expect(mockReplace).not.toHaveBeenCalled();
expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument();
});
it("leaves a user who already has a key on the dashboard", () => {
state.keys = [{ token: "sk-abc" }];
render(<CreateKeyPage />);
expect(mockReplace).not.toHaveBeenCalled();
expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument();
});
it("does not redirect outside the post-login landing, and skips the key lookup entirely", () => {
state.login = null;
render(<CreateKeyPage />);
expect(mockReplace).not.toHaveBeenCalled();
expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument();
expect(mockUseKeys.mock.calls[0][3]).toBe(false);
});
it("holds the loading screen on the landing until the role hydrates, instead of flashing the dashboard", () => {
state.userRole = "";
render(<CreateKeyPage />);
expect(screen.getByTestId("loading-screen")).toBeInTheDocument();
expect(screen.queryByTestId("api-keys-dashboard")).not.toBeInTheDocument();
expect(mockReplace).not.toHaveBeenCalled();
});
it("does not hold the dashboard for an unhydrated role outside the post-login landing", () => {
state.login = null;
state.userRole = "";
render(<CreateKeyPage />);
expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument();
});
it("holds the loading screen while the key lookup is in flight", () => {
state.keysLoading = true;
render(<CreateKeyPage />);
expect(screen.getByTestId("loading-screen")).toBeInTheDocument();
expect(screen.queryByTestId("api-keys-dashboard")).not.toBeInTheDocument();
expect(mockReplace).not.toHaveBeenCalled();
});
it("yields to an explicit return URL instead of the connect redirect", () => {
state.returnUrl = "/ui/models-and-endpoints";
render(<CreateKeyPage />);
expect(mockReplace).not.toHaveBeenCalledWith("/mocked-ui/connect");
});
});

View file

@ -3,6 +3,8 @@
import ApiKeysDashboard from "@/app/(dashboard)/api-keys/ApiKeysDashboard"; import ApiKeysDashboard from "@/app/(dashboard)/api-keys/ApiKeysDashboard";
import LoadingScreen from "@/components/common_components/LoadingScreen"; import LoadingScreen from "@/components/common_components/LoadingScreen";
import { proxyBaseUrl } from "@/components/networking"; import { proxyBaseUrl } from "@/components/networking";
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { internalUserRoles } from "@/utils/roles";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { import {
buildLoginUrlWithReturn, buildLoginUrlWithReturn,
@ -17,7 +19,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef } from "react"; import { Suspense, useEffect, useRef } from "react";
function CreateKeyPageContent() { function CreateKeyPageContent() {
const { authLoading, token } = useAuth(); const { authLoading, token, userRole, userID } = useAuth();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams()!; const searchParams = useSearchParams()!;
@ -26,6 +28,7 @@ function CreateKeyPageContent() {
// Track if we've already attempted a return URL redirect to prevent race conditions // Track if we've already attempted a return URL redirect to prevent race conditions
const hasAttemptedReturnRedirectRef = useRef(false); const hasAttemptedReturnRedirectRef = useRef(false);
const didReturnRedirectRef = useRef(false);
const redirectToLogin = authLoading === false && token === null; const redirectToLogin = authLoading === false && token === null;
@ -75,6 +78,7 @@ function CreateKeyPageContent() {
// Only redirect if the return URL is different from the current URL // Only redirect if the return URL is different from the current URL
// This prevents infinite redirect loops // This prevents infinite redirect loops
if (normalizedReturnUrl !== normalizedCurrentUrl) { if (normalizedReturnUrl !== normalizedCurrentUrl) {
didReturnRedirectRef.current = true;
window.location.replace(safeUrl.href); window.location.replace(safeUrl.href);
} }
} }
@ -83,10 +87,28 @@ function CreateKeyPageContent() {
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
hasAttemptedReturnRedirectRef.current = false; hasAttemptedReturnRedirectRef.current = false;
didReturnRedirectRef.current = false;
} }
}, [token]); }, [token]);
if (authLoading || redirectToLogin || isLegacyRedirect) { const isPostLoginLanding = searchParams.get("login") === "success";
const isSignedIn = !authLoading && Boolean(token);
const isAwaitingRole = isPostLoginLanding && isSignedIn && userRole === "";
const shouldCheckForKeys = isPostLoginLanding && isSignedIn && internalUserRoles.includes(userRole);
const { data: keysData, isLoading: keysLoading } = useKeys(1, 1, { userID }, shouldCheckForKeys);
const isKeylessLanding = shouldCheckForKeys && !keysLoading && keysData?.keys?.length === 0;
const isResolvingKeylessLanding = (shouldCheckForKeys && keysLoading) || isKeylessLanding;
const isResolvingLanding = isAwaitingRole || isResolvingKeylessLanding;
useEffect(() => {
if (isKeylessLanding && !didReturnRedirectRef.current) {
router.replace(migratedHref("connect"));
}
}, [isKeylessLanding, router]);
const isRedirecting = redirectToLogin || isLegacyRedirect || isResolvingLanding;
if (authLoading || isRedirecting) {
return <LoadingScreen />; return <LoadingScreen />;
} }

View file

@ -0,0 +1,230 @@
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "@/../tests/test-utils";
import AiSuggestionModal from "./ai_suggestion_modal";
const { suggestPolicyTemplates, modelHubCall, testPolicyTemplate, enrichPolicyTemplateStream } = vi.hoisted(() => ({
suggestPolicyTemplates: vi.fn(),
modelHubCall: vi.fn(),
testPolicyTemplate: vi.fn(),
enrichPolicyTemplateStream: vi.fn(),
}));
vi.mock("@/components/networking", () => ({
suggestPolicyTemplates,
modelHubCall,
testPolicyTemplate,
enrichPolicyTemplateStream,
}));
const allTemplates = [
{ id: "tpl-pii", title: "PII Protection", description: "Masks PII", guardrails: ["pii-masker"], complexity: "Low" },
{
id: "tpl-inj",
title: "Injection Defense",
description: "Blocks prompt injection",
guardrails: ["prompt-injection"],
complexity: "Medium",
},
];
const suggestResponse = {
selected_templates: [
{ template_id: "tpl-pii", reason: "Your examples contain SSNs" },
{ template_id: "tpl-inj", reason: "Your examples contain instruction overrides" },
],
explanation: "These two cover both risks you described",
};
const defaultProps = {
visible: true,
onSelectTemplates: vi.fn(),
onCancel: vi.fn(),
accessToken: "sk-test",
allTemplates,
};
const renderModal = (props: Partial<typeof defaultProps> = {}) =>
renderWithProviders(<AiSuggestionModal {...defaultProps} {...props} />);
const pickModel = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole("combobox"));
const options = await screen.findAllByText("gpt-5.1");
await user.click(options[options.length - 1]);
};
describe("AiSuggestionModal", () => {
beforeEach(() => {
vi.clearAllMocks();
modelHubCall.mockResolvedValue({ data: [{ model_group: "gpt-5.1" }, { model_group: "claude-opus-4-8" }] });
suggestPolicyTemplates.mockResolvedValue(suggestResponse);
});
it("renders nothing while closed", () => {
renderModal({ visible: false });
expect(screen.queryByText("AI Policy Suggestion")).not.toBeInTheDocument();
});
it("renders the header and prompt copy when opened", async () => {
renderModal();
expect(await screen.findByText("AI Policy Suggestion")).toBeInTheDocument();
expect(
screen.getByText("Describe what you want to block and we'll suggest the best policy templates"),
).toBeInTheDocument();
});
it("loads the model list when opened", async () => {
renderModal();
await waitFor(() => {
expect(modelHubCall).toHaveBeenCalledWith("sk-test");
});
});
it("keeps Suggest disabled until there is both input and a model", async () => {
const user = userEvent.setup();
renderModal();
await screen.findByText("AI Policy Suggestion");
expect(screen.getByRole("button", { name: "Suggest Policies" })).toBeDisabled();
await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII");
expect(screen.getByRole("button", { name: "Suggest Policies" })).toBeDisabled();
await pickModel(user);
expect(screen.getByRole("button", { name: "Suggest Policies" })).not.toBeDisabled();
});
it("sends the examples, description and model to the suggest API", async () => {
const user = userEvent.setup();
renderModal();
await screen.findByText("AI Policy Suggestion");
await user.type(screen.getByPlaceholderText(/Ignore all previous instructions/), "my ssn is 123");
await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII");
await pickModel(user);
await user.click(screen.getByRole("button", { name: "Suggest Policies" }));
await waitFor(() => {
expect(suggestPolicyTemplates).toHaveBeenCalledWith("sk-test", ["my ssn is 123"], "block PII", "gpt-5.1");
});
});
it("adds attack example fields up to the maximum of four", async () => {
const user = userEvent.setup();
renderModal();
await screen.findByText("AI Policy Suggestion");
const countExamples = () => screen.getAllByRole("textbox").length;
const initial = countExamples();
await user.click(screen.getByRole("button", { name: "+ Add another example" }));
expect(countExamples()).toBe(initial + 1);
await user.click(screen.getByRole("button", { name: "+ Add another example" }));
await user.click(screen.getByRole("button", { name: "+ Add another example" }));
expect(countExamples()).toBe(initial + 3);
expect(screen.queryByRole("button", { name: "+ Add another example" })).not.toBeInTheDocument();
});
it("shows each suggested template with the reason it was picked", async () => {
const user = userEvent.setup();
renderModal();
await screen.findByText("AI Policy Suggestion");
await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII");
await pickModel(user);
await user.click(screen.getByRole("button", { name: "Suggest Policies" }));
expect(await screen.findByText("PII Protection")).toBeInTheDocument();
expect(screen.getByText("Injection Defense")).toBeInTheDocument();
expect(screen.getByText("Your examples contain SSNs")).toBeInTheDocument();
expect(screen.getByText("These two cover both risks you described")).toBeInTheDocument();
expect(screen.getByText("2 templates matched your requirements")).toBeInTheDocument();
});
it("preselects every suggestion and reflects the count on the confirm button", async () => {
const user = userEvent.setup();
renderModal();
await screen.findByText("AI Policy Suggestion");
await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII");
await pickModel(user);
await user.click(screen.getByRole("button", { name: "Suggest Policies" }));
expect(await screen.findByRole("button", { name: "Use 2 Selected Templates" })).toBeInTheDocument();
});
it("deselecting a suggestion lowers the confirm count", async () => {
const user = userEvent.setup();
renderModal();
await screen.findByText("AI Policy Suggestion");
await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII");
await pickModel(user);
await user.click(screen.getByRole("button", { name: "Suggest Policies" }));
await user.click(await screen.findByText("PII Protection"));
expect(await screen.findByRole("button", { name: "Use 1 Selected Template" })).toBeInTheDocument();
});
it("hands the selected templates back to the caller", async () => {
const onSelectTemplates = vi.fn();
const user = userEvent.setup();
renderModal({ onSelectTemplates });
await screen.findByText("AI Policy Suggestion");
await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII");
await pickModel(user);
await user.click(screen.getByRole("button", { name: "Suggest Policies" }));
await user.click(await screen.findByRole("button", { name: "Use 2 Selected Templates" }));
expect(onSelectTemplates).toHaveBeenCalledTimes(1);
expect(onSelectTemplates.mock.calls[0][0].map((t: { id: string }) => t.id)).toEqual(["tpl-pii", "tpl-inj"]);
});
it("returns to the input phase from the results phase", async () => {
const user = userEvent.setup();
renderModal();
await screen.findByText("AI Policy Suggestion");
await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII");
await pickModel(user);
await user.click(screen.getByRole("button", { name: "Suggest Policies" }));
await user.click(await screen.findByRole("button", { name: "Back" }));
expect(
await screen.findByText("Describe what you want to block and we'll suggest the best policy templates"),
).toBeInTheDocument();
});
it("reports an empty result set instead of failing silently", async () => {
suggestPolicyTemplates.mockRejectedValue(new Error("boom"));
const user = userEvent.setup();
renderModal();
await screen.findByText("AI Policy Suggestion");
await user.type(screen.getByPlaceholderText(/Block PII leakage/), "block PII");
await pickModel(user);
await user.click(screen.getByRole("button", { name: "Suggest Policies" }));
expect(await screen.findByText("No matching templates found")).toBeInTheDocument();
expect(screen.getByText("Try adjusting your examples or description.")).toBeInTheDocument();
});
it("cancels back to the caller", async () => {
const onCancel = vi.fn();
const user = userEvent.setup();
renderModal({ onCancel });
await screen.findByText("AI Policy Suggestion");
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,13 +1,14 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { Modal, Spin, Checkbox, Select, Input, Typography, Tooltip } from "antd"; import { Button } from "@/components/ui/button";
import { Button, Card } from "@tremor/react"; import { Card, CardContent } from "@/components/ui/card";
import { import { Checkbox } from "@/components/ui/checkbox";
CheckCircleOutlined, import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
CloseCircleOutlined, import { Input } from "@/components/ui/input";
InfoCircleOutlined, import { Textarea } from "@/components/ui/textarea";
DownOutlined, import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
RightOutlined, import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
} from "@ant-design/icons"; import { SearchSelect } from "@/components/shared/SearchSelect";
import { CheckCircle2, ChevronDown, ChevronRight, Info, XCircle } from "lucide-react";
import { import {
suggestPolicyTemplates, suggestPolicyTemplates,
modelHubCall, modelHubCall,
@ -15,9 +16,6 @@ import {
enrichPolicyTemplateStream, enrichPolicyTemplateStream,
} from "@/components/networking"; } from "@/components/networking";
const { TextArea } = Input;
const { Text } = Typography;
interface SuggestedTemplate { interface SuggestedTemplate {
template_id: string; template_id: string;
reason: string; reason: string;
@ -427,7 +425,7 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Checkbox <Checkbox
checked={isSelected} checked={isSelected}
onChange={() => toggleTemplate(suggestion.template_id)} onCheckedChange={() => toggleTemplate(suggestion.template_id)}
className="mt-0.5" className="mt-0.5"
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@ -447,20 +445,25 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
</span> </span>
)} )}
{template.estimated_latency_ms != null && ( {template.estimated_latency_ms != null && (
<Tooltip title="Estimated latency overhead added to each request"> <Tooltip>
<TooltipTrigger
render={
<span <span
className={`px-2 py-0.5 rounded-full text-[10px] font-medium border ${ className={`rounded-full border px-2 py-0.5 text-[10px] font-medium ${
template.estimated_latency_ms <= 1 template.estimated_latency_ms <= 1
? "bg-green-50 text-green-600 border-green-200" ? "border-green-200 bg-green-50 text-green-600"
: "bg-amber-50 text-amber-600 border-amber-200" : "border-amber-200 bg-amber-50 text-amber-600"
}`} }`}
/>
}
> >
+{template.estimated_latency_ms <= 1 ? "<1" : template.estimated_latency_ms}ms latency +{template.estimated_latency_ms <= 1 ? "<1" : template.estimated_latency_ms}ms latency
</span> </TooltipTrigger>
<TooltipContent>Estimated latency overhead added to each request</TooltipContent>
</Tooltip> </Tooltip>
)} )}
</div> </div>
<p className="text-xs text-gray-500 leading-relaxed">{template.description}</p> <p className="text-xs leading-relaxed text-muted-foreground">{template.description}</p>
<div className="flex flex-wrap items-center gap-1.5 mt-2"> <div className="flex flex-wrap items-center gap-1.5 mt-2">
{template.guardrails && {template.guardrails &&
template.guardrails.slice(0, 4).map((g: string) => ( template.guardrails.slice(0, 4).map((g: string) => (
@ -476,7 +479,7 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
)} )}
</div> </div>
<div className="mt-2 flex items-start gap-1.5"> <div className="mt-2 flex items-start gap-1.5">
<InfoCircleOutlined className="text-blue-500 mt-0.5 text-xs shrink-0" /> <Info className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
<p className="text-xs text-blue-600 leading-relaxed">{suggestion.reason}</p> <p className="text-xs text-blue-600 leading-relaxed">{suggestion.reason}</p>
</div> </div>
</div> </div>
@ -490,7 +493,7 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
{explanation && ( {explanation && (
<div className="p-3 bg-gray-50 rounded-xl border border-gray-200"> <div className="p-3 bg-gray-50 rounded-xl border border-gray-200">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<InfoCircleOutlined className="text-gray-400 text-xs" /> <Info className="size-3.5 text-muted-foreground" />
<span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider"> <span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider">
Why these templates Why these templates
</span> </span>
@ -555,7 +558,7 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{hasEnrichedGuardrails ? ( {hasEnrichedGuardrails ? (
<CheckCircleOutlined className="text-green-600" /> <CheckCircle2 className="size-4 text-green-600" />
) : ( ) : (
<svg className="w-4 h-4 text-amber-600 shrink-0" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-4 h-4 text-amber-600 shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path <path
@ -572,33 +575,29 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
size="small"
placeholder="e.g. Emirates Airlines" placeholder="e.g. Emirates Airlines"
value={enrichBrandName} value={enrichBrandName}
onChange={(e) => setEnrichBrandName(e.target.value)} onChange={(e) => setEnrichBrandName(e.target.value)}
onPressEnter={() => enrichBrandName.trim() && handleEnrichCompetitors()} onKeyDown={(e) => {
if (e.key === "Enter" && enrichBrandName.trim() && !isEnriching) handleEnrichCompetitors();
}}
className="flex-1" className="flex-1"
/> />
<Button <Button size="sm" onClick={handleEnrichCompetitors} disabled={!enrichBrandName.trim() || isEnriching}>
size="xs"
onClick={handleEnrichCompetitors}
loading={isEnriching}
disabled={!enrichBrandName.trim() || isEnriching}
>
{isEnriching ? "Discovering..." : hasEnrichedGuardrails ? "Re-discover" : "Discover"} {isEnriching ? "Discovering..." : hasEnrichedGuardrails ? "Re-discover" : "Discover"}
</Button> </Button>
</div> </div>
{isEnriching && enrichStatusMessage && ( {isEnriching && enrichStatusMessage && (
<div className="flex items-center gap-2 p-2 bg-blue-50 rounded-sm border border-blue-100"> <div className="flex items-center gap-2 rounded-sm border border-border bg-muted p-2">
<Spin size="small" /> <UiLoadingSpinner className="size-3" />
<span className="text-xs text-blue-700">{enrichStatusMessage}</span> <span className="text-xs text-blue-700">{enrichStatusMessage}</span>
</div> </div>
)} )}
{hasEnrichedGuardrails && ( {hasEnrichedGuardrails && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircleOutlined className="text-green-600" /> <CheckCircle2 className="size-4 text-green-600" />
<span className="text-xs text-green-800">Competitor names loaded for {enrichBrandName}</span> <span className="text-xs text-green-800">Competitor names loaded for {enrichBrandName}</span>
</div> </div>
)} )}
@ -631,33 +630,29 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">Input Text</label> <label className="text-sm font-medium text-gray-700">Input Text</label>
<Tooltip title="Press Enter to submit. Use Shift+Enter for new line."> <Tooltip>
<InfoCircleOutlined className="text-gray-400 cursor-help" /> <TooltipTrigger render={<Info className="size-3.5 cursor-help text-muted-foreground" />} />
<TooltipContent>Press Enter to submit. Use Shift+Enter for new line.</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<Text className="text-xs text-gray-500">Characters: {testInputText.length}</Text> <span className="text-xs text-muted-foreground">Characters: {testInputText.length}</span>
</div> </div>
<TextArea <Textarea
value={testInputText} value={testInputText}
onChange={(e) => setTestInputText(e.target.value)} onChange={(e) => setTestInputText(e.target.value)}
onKeyDown={handleTestKeyDown} onKeyDown={handleTestKeyDown}
placeholder="Enter text to test against all selected policy guardrails..." placeholder="Enter text to test against all selected policy guardrails..."
rows={4} rows={4}
className="font-mono text-sm" className="field-sizing-fixed font-mono text-sm"
/> />
<div className="mt-1"> <div className="mt-1">
<Text className="text-xs text-gray-500"> <span className="text-xs text-muted-foreground">
Press <kbd className="px-1 py-0.5 bg-gray-100 border border-gray-300 rounded-sm text-xs">Enter</kbd> to Press <kbd className="rounded-sm border border-border bg-muted px-1 py-0.5 text-xs">Enter</kbd> to
submit submit
</Text> </span>
</div> </div>
</div> </div>
<Button <Button onClick={handleRunTest} disabled={!testInputText.trim() || isTestLoading} className="w-full">
onClick={handleRunTest}
loading={isTestLoading}
disabled={!testInputText.trim() || isTestLoading}
className="w-full"
>
{isTestLoading {isTestLoading
? `Testing ${allSelectedGuardrailDefs.length} guardrails...` ? `Testing ${allSelectedGuardrailDefs.length} guardrails...`
: `Test ${allSelectedGuardrailDefs.length} guardrails`} : `Test ${allSelectedGuardrailDefs.length} guardrails`}
@ -715,7 +710,7 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
return ( return (
<Card <Card
key={result.guardrail_name} key={result.guardrail_name}
className={`p-3! ${ className={`${
isBlocked isBlocked
? "bg-red-50 border-red-200" ? "bg-red-50 border-red-200"
: isMasked : isMasked
@ -725,19 +720,19 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
: "bg-gray-50 border-gray-200" : "bg-gray-50 border-gray-200"
}`} }`}
> >
<div className="space-y-2"> <CardContent className="space-y-2 py-3">
<div <div
className="flex items-center justify-between cursor-pointer" className="flex items-center justify-between cursor-pointer"
onClick={() => toggleResultCollapse(result.guardrail_name)} onClick={() => toggleResultCollapse(result.guardrail_name)}
> >
<div className="flex items-center space-x-1.5"> <div className="flex items-center space-x-1.5">
{isCollapsed ? ( {isCollapsed ? (
<RightOutlined className="text-gray-500 text-[10px]" /> <ChevronRight className="size-3 text-muted-foreground" />
) : ( ) : (
<DownOutlined className="text-gray-500 text-[10px]" /> <ChevronDown className="size-3 text-muted-foreground" />
)} )}
{isBlocked ? ( {isBlocked ? (
<CloseCircleOutlined className="text-red-600" /> <XCircle className="size-4 text-destructive" />
) : isMasked ? ( ) : isMasked ? (
<svg className="w-4 h-4 text-amber-600" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-4 h-4 text-amber-600" fill="currentColor" viewBox="0 0 20 20">
<path <path
@ -747,7 +742,7 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
/> />
</svg> </svg>
) : ( ) : (
<CheckCircleOutlined className="text-green-600" /> <CheckCircle2 className="size-4 text-green-600" />
)} )}
<span <span
className={`text-xs font-medium ${isBlocked ? "text-red-800" : isMasked ? "text-amber-800" : "text-green-800"}`} className={`text-xs font-medium ${isBlocked ? "text-red-800" : isMasked ? "text-amber-800" : "text-green-800"}`}
@ -789,7 +784,7 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
{isPassed && <div className="text-[10px] text-green-700">Passed unchanged.</div>} {isPassed && <div className="text-[10px] text-green-700">Passed unchanged.</div>}
</> </>
)} )}
</div> </CardContent>
</Card> </Card>
); );
})} })}
@ -798,32 +793,28 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
})()} })()}
{testResults && testResults.length === 0 && !isTestLoading && ( {testResults && testResults.length === 0 && !isTestLoading && (
<p className="text-xs text-gray-400 text-center py-3">No testable guardrails in selected templates.</p> <p className="py-3 text-center text-xs text-muted-foreground">
No testable guardrails in selected templates.
</p>
)} )}
</div> </div>
); );
}; };
return ( return (
<Modal <Dialog open={visible} onOpenChange={(open) => !open && handleCancel()}>
title={null} <DialogContent className={showTestPanel ? "gap-0 p-0 sm:max-w-300" : "gap-0 p-0 sm:max-w-205"}>
open={visible}
onCancel={handleCancel}
width={showTestPanel ? 1200 : 820}
footer={null}
styles={{ body: { padding: 0 } }}
>
{/* Header */} {/* Header */}
<div className="px-8 pt-8 pb-4"> <div className="px-8 pt-8 pb-4">
<h3 className="text-xl font-semibold text-gray-900 mb-1">AI Policy Suggestion</h3> <DialogTitle className="mb-1 text-xl font-semibold">AI Policy Suggestion</DialogTitle>
<p className="text-sm text-gray-500"> <p className="text-sm text-muted-foreground">
{showResults {showResults
? `${suggestions?.length || 0} template${(suggestions?.length || 0) !== 1 ? "s" : ""} matched your requirements` ? `${suggestions?.length || 0} template${(suggestions?.length || 0) !== 1 ? "s" : ""} matched your requirements`
: "Describe what you want to block and we'll suggest the best policy templates"} : "Describe what you want to block and we'll suggest the best policy templates"}
</p> </p>
</div> </div>
<div className="border-t border-gray-100" /> <div className="border-t border-border" />
{!showResults ? ( {!showResults ? (
/* ── Input phase ── */ /* ── Input phase ── */
@ -834,16 +825,13 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
Model Model
<span className="text-red-500 ml-0.5">*</span> <span className="text-red-500 ml-0.5">*</span>
</label> </label>
<Select <SearchSelect
placeholder="Select a model to analyze your requirements"
value={selectedModel}
onChange={(value) => setSelectedModel(value)}
loading={isLoadingModels}
showSearch
size="large"
className="w-full"
options={availableModels.map((m) => ({ label: m, value: m }))} options={availableModels.map((m) => ({ label: m, value: m }))}
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())} value={selectedModel}
onValueChange={(value) => setSelectedModel(value || undefined)}
placeholder={isLoadingModels ? "Loading models..." : "Select a model to analyze your requirements"}
emptyText="No models found"
disabled={isLoadingModels}
/> />
</div> </div>
@ -893,7 +881,10 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
))} ))}
</div> </div>
{attackExamples.length < MAX_EXAMPLES && ( {attackExamples.length < MAX_EXAMPLES && (
<button onClick={handleAddExample} className="text-sm text-blue-600 hover:text-blue-800 mt-2 font-medium"> <button
onClick={handleAddExample}
className="text-sm text-blue-600 hover:text-blue-800 mt-2 font-medium"
>
+ Add another example + Add another example
</button> </button>
)} )}
@ -938,9 +929,9 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
{/* Loading state */} {/* Loading state */}
{isLoading && ( {isLoading && (
<div className="flex items-center justify-center gap-3 p-4 bg-gray-50 rounded-lg border border-gray-200"> <div className="flex items-center justify-center gap-3 rounded-lg border border-border bg-muted p-4">
<Spin size="small" /> <UiLoadingSpinner className="size-4" />
<span className="text-sm text-gray-600">Analyzing your requirements...</span> <span className="text-sm text-muted-foreground">Analyzing your requirements...</span>
</div> </div>
)} )}
@ -949,7 +940,7 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
<Button variant="secondary" onClick={handleCancel} disabled={isLoading}> <Button variant="secondary" onClick={handleCancel} disabled={isLoading}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleSuggest} loading={isLoading} disabled={!hasInput || !selectedModel || isLoading}> <Button onClick={handleSuggest} disabled={!hasInput || !selectedModel || isLoading}>
{isLoading ? "Analyzing..." : "Suggest Policies"} {isLoading ? "Analyzing..." : "Suggest Policies"}
</Button> </Button>
</div> </div>
@ -986,7 +977,8 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
</div> </div>
</div> </div>
)} )}
</Modal> </DialogContent>
</Dialog>
); );
}; };

View file

@ -1,6 +1,17 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Modal, Checkbox, Button, Divider, Tag } from "antd"; import { Badge } from "@/components/ui/badge";
import { CheckCircleOutlined, InfoCircleOutlined } from "@ant-design/icons"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { CheckCircle2, Info } from "lucide-react";
interface GuardrailInfo { interface GuardrailInfo {
guardrail_name: string; guardrail_name: string;
@ -79,65 +90,43 @@ const GuardrailSelectionModal: React.FC<GuardrailSelectionModalProps> = ({
const selectedCount = selectedGuardrails.size; const selectedCount = selectedGuardrails.size;
return ( return (
<Modal <Dialog open={visible} onOpenChange={(open) => !open && onCancel()}>
title={ <DialogContent className="sm:max-w-175">
<div> <DialogHeader>
<div className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2 text-lg">
<h3 className="text-lg font-semibold mb-0">{template?.title}</h3> {template?.title}
{progressInfo && ( {progressInfo && (
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-600 border border-blue-100"> <Badge variant="secondary">
Template {progressInfo.current} of {progressInfo.total} Template {progressInfo.current} of {progressInfo.total}
</span> </Badge>
)} )}
</div> </DialogTitle>
<p className="text-sm text-gray-500 font-normal mt-1"> <DialogDescription>Review and select guardrails to create for this template</DialogDescription>
Review and select guardrails to create for this template </DialogHeader>
</p>
</div>
}
open={visible}
onCancel={onCancel}
width={700}
footer={[
<Button key="cancel" onClick={onCancel} disabled={isLoading}>
Cancel
</Button>,
<Button
key="confirm"
type="primary"
onClick={handleConfirm}
loading={isLoading}
disabled={selectedCount === 0 && existingCount === 0}
>
{selectedCount > 0
? `Create ${selectedCount} Guardrail${selectedCount > 1 ? "s" : ""} & Use Template`
: "Use Template"}
</Button>,
]}
>
<div className="py-4"> <div className="py-4">
{/* Summary Stats */} {/* Summary Stats */}
<div className="flex items-center gap-4 mb-4 p-3 bg-blue-50 rounded-lg border border-blue-100"> <div className="mb-4 flex items-center gap-4 rounded-lg border border-border bg-muted p-3">
<InfoCircleOutlined className="text-blue-600 text-lg" /> <Info className="size-4 text-muted-foreground" />
<div className="flex-1"> <div className="flex-1">
<div className="text-sm"> <div className="text-sm">
<span className="font-medium text-gray-900">{guardrailsInfo.length} total guardrails</span> <span className="font-medium">{guardrailsInfo.length} total guardrails</span>
<span className="text-gray-600 mx-2">•</span> <span className="mx-2 text-muted-foreground">•</span>
<span className="text-green-600 font-medium">{newGuardrailsCount} new</span> <span className="font-medium text-green-600">{newGuardrailsCount} new</span>
{existingCount > 0 && ( {existingCount > 0 && (
<> <>
<span className="text-gray-600 mx-2">•</span> <span className="mx-2 text-muted-foreground">•</span>
<span className="text-gray-600">{existingCount} already exist</span> <span className="text-muted-foreground">{existingCount} already exist</span>
</> </>
)} )}
</div> </div>
</div> </div>
{newGuardrailsCount > 0 && ( {newGuardrailsCount > 0 && (
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="small" onClick={handleSelectAll}> <Button variant="outline" size="sm" onClick={handleSelectAll}>
Select All New Select All New
</Button> </Button>
<Button size="small" onClick={handleDeselectAll}> <Button variant="outline" size="sm" onClick={handleDeselectAll}>
Deselect All Deselect All
</Button> </Button>
</div> </div>
@ -149,49 +138,41 @@ const GuardrailSelectionModal: React.FC<GuardrailSelectionModalProps> = ({
{guardrailsInfo.map((guardrail) => ( {guardrailsInfo.map((guardrail) => (
<div <div
key={guardrail.guardrail_name} key={guardrail.guardrail_name}
className={`border rounded-lg p-4 ${ className={`rounded-lg border p-4 transition-colors ${
guardrail.alreadyExists guardrail.alreadyExists ? "border-border bg-muted/50" : "border-border bg-card hover:border-ring"
? "bg-gray-50 border-gray-200" }`}
: "bg-white border-gray-300 hover:border-blue-400"
} transition-colors`}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="shrink-0 pt-0.5"> <div className="shrink-0 pt-0.5">
{guardrail.alreadyExists ? ( {guardrail.alreadyExists ? (
<CheckCircleOutlined className="text-green-600 text-lg" /> <CheckCircle2 className="size-4 text-green-600" />
) : ( ) : (
<Checkbox <Checkbox
checked={selectedGuardrails.has(guardrail.guardrail_name)} checked={selectedGuardrails.has(guardrail.guardrail_name)}
onChange={() => handleToggle(guardrail.guardrail_name)} onCheckedChange={() => handleToggle(guardrail.guardrail_name)}
/> />
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="font-mono text-sm font-medium text-gray-900">{guardrail.guardrail_name}</span> <span className="font-mono text-sm font-medium">{guardrail.guardrail_name}</span>
{guardrail.alreadyExists && ( {guardrail.alreadyExists && <Badge variant="secondary">Already exists</Badge>}
<Tag color="green" className="text-xs">
Already exists
</Tag>
)}
</div> </div>
<p className="text-sm text-gray-600">{guardrail.description}</p> <p className="text-sm text-muted-foreground">{guardrail.description}</p>
{/* Show guardrail type and mode */} {/* Show guardrail type and mode */}
<div className="flex gap-2 mt-2"> <div className="flex gap-2 mt-2">
<Tag className="text-xs">{guardrail.definition?.litellm_params?.guardrail || "unknown"}</Tag> <Badge variant="outline">{guardrail.definition?.litellm_params?.guardrail || "unknown"}</Badge>
<Tag className="text-xs" color="blue"> <Badge variant="secondary">{guardrail.definition?.litellm_params?.mode || "unknown"}</Badge>
{guardrail.definition?.litellm_params?.mode || "unknown"}
</Tag>
{guardrail.definition?.litellm_params?.patterns && ( {guardrail.definition?.litellm_params?.patterns && (
<Tag className="text-xs" color="purple"> <Badge variant="secondary">
{guardrail.definition.litellm_params.patterns.length} pattern(s) {guardrail.definition.litellm_params.patterns.length} pattern(s)
</Tag> </Badge>
)} )}
{guardrail.definition?.litellm_params?.categories && ( {guardrail.definition?.litellm_params?.categories && (
<Tag className="text-xs" color="orange"> <Badge variant="secondary">
{guardrail.definition.litellm_params.categories.length} category/categories {guardrail.definition.litellm_params.categories.length} category/categories
</Tag> </Badge>
)} )}
</div> </div>
</div> </div>
@ -201,7 +182,7 @@ const GuardrailSelectionModal: React.FC<GuardrailSelectionModalProps> = ({
</div> </div>
{guardrailsInfo.length === 0 && ( {guardrailsInfo.length === 0 && (
<div className="text-center py-8 text-gray-500"> <div className="py-8 text-center text-muted-foreground">
<p>No guardrails defined for this template.</p> <p>No guardrails defined for this template.</p>
<p className="text-sm mt-2">This template will use existing guardrails in your system.</p> <p className="text-sm mt-2">This template will use existing guardrails in your system.</p>
</div> </div>
@ -210,48 +191,60 @@ const GuardrailSelectionModal: React.FC<GuardrailSelectionModalProps> = ({
{/* Discovered Competitors */} {/* Discovered Competitors */}
{template?.discoveredCompetitors?.length > 0 && ( {template?.discoveredCompetitors?.length > 0 && (
<> <>
<Divider /> <Separator className="my-4" />
<div className="p-3 bg-purple-50 rounded-lg border border-purple-100"> <div className="rounded-lg border border-border bg-muted p-3">
<div className="flex items-center gap-2 mb-2"> <div className="mb-2 flex items-center gap-2">
<span className="text-lg">✨</span> <span className="text-lg">✨</span>
<span className="font-medium text-purple-900 text-sm"> <span className="text-sm font-medium">
AI-Discovered Competitors ({template.discoveredCompetitors.length}) AI-Discovered Competitors ({template.discoveredCompetitors.length})
</span> </span>
</div> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{template.discoveredCompetitors.map((name: string) => ( {template.discoveredCompetitors.map((name: string) => (
<Tag key={name} color="purple" className="text-xs"> <Badge key={name} variant="secondary">
{name} {name}
</Tag> </Badge>
))} ))}
</div> </div>
<p className="text-xs text-purple-600 mt-2"> <p className="mt-2 text-xs text-muted-foreground">
These competitor names will be automatically blocked by the competitor-name-blocker guardrail. These competitor names will be automatically blocked by the competitor-name-blocker guardrail.
</p> </p>
</div> </div>
</> </>
)} )}
<Divider /> <Separator className="my-4" />
{/* Selected Summary */} {/* Selected Summary */}
<div className="text-sm text-gray-600"> <div className="text-sm text-muted-foreground">
{selectedCount > 0 ? ( {selectedCount > 0 ? (
<p> <p>
<span className="font-medium text-gray-900">{selectedCount}</span> guardrail{selectedCount > 1 ? "s" : ""}{" "} <span className="font-medium text-foreground">{selectedCount}</span> guardrail
will be created {selectedCount > 1 ? "s" : ""} will be created
</p> </p>
) : existingCount > 0 ? ( ) : existingCount > 0 ? (
<p className="text-green-600">All guardrails already exist. You can proceed to use this template.</p> <p className="text-green-600">All guardrails already exist. You can proceed to use this template.</p>
) : ( ) : (
<p className="text-orange-600"> <p className="text-amber-600">
Select at least one guardrail to create, or click &quot;Use Template&quot; to proceed without creating new Select at least one guardrail to create, or click &quot;Use Template&quot; to proceed without creating
guardrails. new guardrails.
</p> </p>
)} )}
</div> </div>
</div> </div>
</Modal>
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={isLoading}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={isLoading || (selectedCount === 0 && existingCount === 0)}>
{selectedCount > 0
? `Create ${selectedCount} Guardrail${selectedCount > 1 ? "s" : ""} & Use Template`
: "Use Template"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
); );
}; };

View file

@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { Alert, Tag, Typography } from "antd"; import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { Badge } from "@/components/ui/badge";
const { Text } = Typography; import { AlertTriangle, Info } from "lucide-react";
interface ImpactResult { interface ImpactResult {
affected_keys_count: number; affected_keys_count: number;
@ -14,21 +14,39 @@ interface ImpactPreviewAlertProps {
impactResult: ImpactResult; impactResult: ImpactResult;
} }
interface SampleListProps {
label: string;
samples: string[];
totalCount: number;
}
const SampleList: React.FC<SampleListProps> = ({ label, samples, totalCount }) => (
<div className="mt-1 flex flex-wrap items-center gap-1">
<span className="text-xs text-muted-foreground">{label}: </span>
{samples.slice(0, 5).map((sample) => (
<Badge key={sample} variant="outline">
{sample}
</Badge>
))}
{totalCount > 5 && <span className="text-xs text-muted-foreground">and {totalCount - 5} more...</span>}
</div>
);
const ImpactPreviewAlert: React.FC<ImpactPreviewAlertProps> = ({ impactResult }) => { const ImpactPreviewAlert: React.FC<ImpactPreviewAlertProps> = ({ impactResult }) => {
const isGlobal = impactResult.affected_keys_count === -1;
return ( return (
<Alert <Alert className="mb-4">
type={impactResult.affected_keys_count === -1 ? "warning" : "info"} {isGlobal ? <AlertTriangle /> : <Info />}
showIcon <AlertTitle>Impact Preview</AlertTitle>
className="mb-4" <AlertDescription>
message="Impact Preview" {isGlobal ? (
description={ <span>
impactResult.affected_keys_count === -1 ? (
<Text>
Global scope — this will affect <strong>all keys and teams</strong>. Global scope — this will affect <strong>all keys and teams</strong>.
</Text> </span>
) : ( ) : (
<div> <div>
<Text> <span>
This attachment would affect{" "} This attachment would affect{" "}
<strong> <strong>
{impactResult.affected_keys_count} key{impactResult.affected_keys_count !== 1 ? "s" : ""} {impactResult.affected_keys_count} key{impactResult.affected_keys_count !== 1 ? "s" : ""}
@ -38,45 +56,25 @@ const ImpactPreviewAlert: React.FC<ImpactPreviewAlertProps> = ({ impactResult })
{impactResult.affected_teams_count} team{impactResult.affected_teams_count !== 1 ? "s" : ""} {impactResult.affected_teams_count} team{impactResult.affected_teams_count !== 1 ? "s" : ""}
</strong> </strong>
. .
</Text> </span>
{impactResult.sample_keys.length > 0 && ( {impactResult.sample_keys.length > 0 && (
<div className="mt-1"> <SampleList
<Text type="secondary" style={{ fontSize: 12 }}> label="Keys"
Keys:{" "} samples={impactResult.sample_keys}
</Text> totalCount={impactResult.affected_keys_count}
{impactResult.sample_keys.slice(0, 5).map((k: string) => ( />
<Tag key={k} style={{ fontSize: 11 }}>
{k}
</Tag>
))}
{impactResult.affected_keys_count > 5 && (
<Text type="secondary" style={{ fontSize: 11 }}>
and {impactResult.affected_keys_count - 5} more...
</Text>
)}
</div>
)} )}
{impactResult.sample_teams.length > 0 && ( {impactResult.sample_teams.length > 0 && (
<div className="mt-1"> <SampleList
<Text type="secondary" style={{ fontSize: 12 }}> label="Teams"
Teams:{" "} samples={impactResult.sample_teams}
</Text> totalCount={impactResult.affected_teams_count}
{impactResult.sample_teams.slice(0, 5).map((t: string) => (
<Tag key={t} style={{ fontSize: 11 }}>
{t}
</Tag>
))}
{impactResult.affected_teams_count > 5 && (
<Text type="secondary" style={{ fontSize: 11 }}>
and {impactResult.affected_teams_count - 5} more...
</Text>
)}
</div>
)}
</div>
)
}
/> />
)}
</div>
)}
</AlertDescription>
</Alert>
); );
}; };

View file

@ -1,9 +1,10 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; import { Alert, AlertDescription, AlertTitle, AlertAction } from "@/components/shared/Alert";
import { Alert } from "antd"; import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import MessageManager from "@/components/molecules/message_manager"; import MessageManager from "@/components/molecules/message_manager";
import { InfoCircleOutlined } from "@ant-design/icons"; import { Info, TriangleAlert, X } from "lucide-react";
import { isAdminRole } from "@/utils/roles"; import { isAdminRole } from "@/utils/roles";
import PolicyTable from "./PolicyTable"; import PolicyTable from "./PolicyTable";
import PolicyInfoView from "./policy_info"; import PolicyInfoView from "./policy_info";
@ -33,6 +34,53 @@ import { Policy, PolicyAttachment } from "@/components/policies/types";
import { Guardrail } from "@/components/guardrails/types"; import { Guardrail } from "@/components/guardrails/types";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
interface DismissibleAlertProps {
title: string;
icon: React.ReactNode;
children?: React.ReactNode;
}
const DismissibleAlert: React.FC<DismissibleAlertProps> = ({ title, icon, children }) => {
const [isDismissed, setIsDismissed] = useState(false);
if (isDismissed) return null;
return (
<Alert className="mb-6">
{icon}
<AlertTitle>{title}</AlertTitle>
{children && <AlertDescription>{children}</AlertDescription>}
<AlertAction>
<Button variant="ghost" size="icon-sm" onClick={() => setIsDismissed(true)} aria-label={`Dismiss ${title}`}>
<X />
</Button>
</AlertAction>
</Alert>
);
};
const AboutPoliciesAlert = () => (
<DismissibleAlert title="About Policies" icon={<Info />}>
<p className="mb-3">
Use policies to group guardrails and control which ones run for specific teams, keys, or models.
</p>
<p className="mb-2 font-semibold">Why use policies?</p>
<ul className="mb-3 ml-2 list-inside list-disc space-y-1">
<li>Enable/disable specific guardrails for teams, keys, or models</li>
<li>Group guardrails into a single policy</li>
<li>Inherit from existing policies and override what you need</li>
</ul>
<a
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies"
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-block text-primary underline underline-offset-4"
>
Learn more in the documentation -&gt;
</a>
</DismissibleAlert>
);
interface PoliciesPanelProps { interface PoliciesPanelProps {
accessToken: string | null; accessToken: string | null;
userRole?: string; userRole?: string;
@ -48,7 +96,7 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({ accessToken, userRole })
const [isAddAttachmentModalVisible, setIsAddAttachmentModalVisible] = useState(false); const [isAddAttachmentModalVisible, setIsAddAttachmentModalVisible] = useState(false);
const [editingPolicy, setEditingPolicy] = useState<Policy | null>(null); const [editingPolicy, setEditingPolicy] = useState<Policy | null>(null);
const [selectedPolicyId, setSelectedPolicyId] = useState<string | null>(null); const [selectedPolicyId, setSelectedPolicyId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<number>(0); const [activeTab, setActiveTab] = useState<string>("templates");
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [policyToDelete, setPolicyToDelete] = useState<Policy | null>(null); const [policyToDelete, setPolicyToDelete] = useState<Policy | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@ -315,7 +363,7 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({ accessToken, userRole })
// Pre-fill the add policy form with template data // Pre-fill the add policy form with template data
setEditingPolicy(selectedTemplate.templateData as Policy); setEditingPolicy(selectedTemplate.templateData as Policy);
setIsAddPolicyModalVisible(true); setIsAddPolicyModalVisible(true);
setActiveTab(1); // Switch to Policies tab (now at index 1) setActiveTab("policies");
// Show success message // Show success message
if (createdGuardrails.length > 0) { if (createdGuardrails.length > 0) {
@ -359,86 +407,37 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({ accessToken, userRole })
}; };
return ( return (
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2"> <div className="m-8 mx-auto w-full flex-auto overflow-y-auto p-2">
<TabGroup index={activeTab} onIndexChange={setActiveTab}> <Tabs value={activeTab} onValueChange={setActiveTab}>
<TabList className="mb-4"> <TabsList className="mb-4">
<Tab>Templates</Tab> <TabsTrigger value="templates" className="flex-none">
<Tab>Policies</Tab> Templates
<Tab>Attachments</Tab> </TabsTrigger>
<Tab>Policy Simulator</Tab> <TabsTrigger value="policies" className="flex-none">
</TabList> Policies
</TabsTrigger>
<TabsTrigger value="attachments" className="flex-none">
Attachments
</TabsTrigger>
<TabsTrigger value="simulator" className="flex-none">
Policy Simulator
</TabsTrigger>
</TabsList>
<TabPanels> <TabsContent value="templates">
<TabPanel> <AboutPoliciesAlert />
<Alert
message="About Policies"
description={
<div>
<p className="mb-3">
Use policies to group guardrails and control which ones run for specific teams, keys, or models.
</p>
<p className="mb-2 font-semibold">Why use policies?</p>
<ul className="list-disc list-inside mb-3 space-y-1 ml-2">
<li>Enable/disable specific guardrails for teams, keys, or models</li>
<li>Group guardrails into a single policy</li>
<li>Inherit from existing policies and override what you need</li>
</ul>
<a
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline inline-block mt-1"
>
Learn more in the documentation →
</a>
</div>
}
type="info"
icon={<InfoCircleOutlined />}
showIcon
closable
className="mb-6"
/>
<PolicyTemplates <PolicyTemplates
onUseTemplate={handleUseTemplate} onUseTemplate={handleUseTemplate}
onOpenAiSuggestion={() => setIsAiSuggestionModalOpen(true)} onOpenAiSuggestion={() => setIsAiSuggestionModalOpen(true)}
onTemplatesLoaded={setLoadedTemplates} onTemplatesLoaded={setLoadedTemplates}
accessToken={accessToken} accessToken={accessToken}
/> />
</TabPanel> </TabsContent>
<TabPanel> <TabsContent value="policies">
<Alert <AboutPoliciesAlert />
message="About Policies"
description={
<div>
<p className="mb-3">
Use policies to group guardrails and control which ones run for specific teams, keys, or models.
</p>
<p className="mb-2 font-semibold">Why use policies?</p>
<ul className="list-disc list-inside mb-3 space-y-1 ml-2">
<li>Enable/disable specific guardrails for teams, keys, or models</li>
<li>Group guardrails into a single policy</li>
<li>Inherit from existing policies and override what you need</li>
</ul>
<a
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline inline-block mt-1"
>
Learn more in the documentation →
</a>
</div>
}
type="info"
icon={<InfoCircleOutlined />}
showIcon
closable
className="mb-6"
/>
<div className="flex justify-between items-center mb-4"> <div className="mb-4 flex items-center justify-between">
<Button onClick={handleAddPolicy} disabled={!accessToken}> <Button onClick={handleAddPolicy} disabled={!accessToken}>
+ Add New Policy + Add New Policy
</Button> </Button>
@ -502,38 +501,16 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({ accessToken, userRole })
onOk={handleDeleteConfirm} onOk={handleDeleteConfirm}
confirmLoading={isDeleting} confirmLoading={isDeleting}
/> />
</TabsContent>
<GuardrailSelectionModal <TabsContent value="attachments">
visible={isGuardrailSelectionModalOpen} <DismissibleAlert title="About Policy Attachments" icon={<Info />}>
template={selectedTemplate}
existingGuardrails={existingGuardrailNames}
onConfirm={handleGuardrailSelectionConfirm}
onCancel={handleGuardrailSelectionCancel}
isLoading={isCreatingGuardrails}
progressInfo={templateQueueProgress}
/>
<TemplateParameterModal
visible={isParameterModalOpen}
template={pendingTemplate}
onConfirm={handleParameterConfirm}
onCancel={handleParameterCancel}
isLoading={isEnrichingTemplate}
accessToken={accessToken || ""}
/>
</TabPanel>
<TabPanel>
<Alert
message="About Policy Attachments"
description={
<div>
<p className="mb-3"> <p className="mb-3">
Policy attachments control where your policies apply. Policies don&apos;t do anything until you Policy attachments control where your policies apply. Policies don&apos;t do anything until you attach
attach them to specific teams, keys, models, tags, or globally. them to specific teams, keys, models, tags, or globally.
</p> </p>
<p className="mb-2 font-semibold">Attachment Scopes:</p> <p className="mb-2 font-semibold">Attachment Scopes:</p>
<ul className="list-disc list-inside mb-3 space-y-1 ml-2"> <ul className="mb-3 ml-2 list-inside list-disc space-y-1">
<li> <li>
<strong>Global (*)</strong> - Applies to all requests <strong>Global (*)</strong> - Applies to all requests
</li> </li>
@ -547,39 +524,27 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({ accessToken, userRole })
<strong>Models</strong> - Applies only when specific models are used <strong>Models</strong> - Applies only when specific models are used
</li> </li>
<li> <li>
<strong>Tags</strong> - Matches tags from key/team <code>metadata.tags</code> or tags passed <strong>Tags</strong> - Matches tags from key/team <code>metadata.tags</code> or tags passed dynamically
dynamically in the request body (<code>metadata.tags</code>). Use this to enforce policies across in the request body (<code>metadata.tags</code>). Use this to enforce policies across groups, e.g.
groups, e.g. &quot;all keys tagged <code>healthcare</code> get HIPAA guardrails.&quot; Supports &quot;all keys tagged <code>healthcare</code> get HIPAA guardrails.&quot; Supports wildcards (
wildcards (<code>prod-*</code>). <code>prod-*</code>).
</li> </li>
</ul> </ul>
<a <a
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies#attachments" href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies#attachments"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline inline-block mt-1" className="mt-1 inline-block text-primary underline underline-offset-4"
> >
Learn more about attachments → Learn more about attachments -&gt;
</a> </a>
</div> </DismissibleAlert>
}
type="info"
icon={<InfoCircleOutlined />}
showIcon
closable
className="mb-6"
/>
<Alert <DismissibleAlert title="Enterprise Feature Notice" icon={<TriangleAlert />}>
message="Enterprise Feature Notice" Parts of policy attachments will be on LiteLLM Enterprise in subsequent releases.
description="Parts of policy attachments will be on LiteLLM Enterprise in subsequent releases." </DismissibleAlert>
type="warning"
showIcon
closable
className="mb-6"
/>
<div className="flex justify-between items-center mb-4"> <div className="mb-4 flex items-center justify-between">
<Button <Button
onClick={() => setIsAddAttachmentModalVisible(true)} onClick={() => setIsAddAttachmentModalVisible(true)}
disabled={!accessToken || policiesList.length === 0} disabled={!accessToken || policiesList.length === 0}
@ -604,13 +569,12 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({ accessToken, userRole })
policies={policiesList} policies={policiesList}
createAttachment={createPolicyAttachmentCall} createAttachment={createPolicyAttachmentCall}
/> />
</TabPanel> </TabsContent>
<TabPanel> <TabsContent value="simulator">
<PolicyTestPanel accessToken={accessToken} /> <PolicyTestPanel accessToken={accessToken} />
</TabPanel> </TabsContent>
</TabPanels> </Tabs>
</TabGroup>
<DeleteResourceModal <DeleteResourceModal
isOpen={isDeleteAttachmentModalOpen} isOpen={isDeleteAttachmentModalOpen}
@ -627,6 +591,25 @@ const PoliciesPanel: React.FC<PoliciesPanelProps> = ({ accessToken, userRole })
confirmLoading={deleteAttachmentMutation.isPending} confirmLoading={deleteAttachmentMutation.isPending}
/> />
<GuardrailSelectionModal
visible={isGuardrailSelectionModalOpen}
template={selectedTemplate}
existingGuardrails={existingGuardrailNames}
onConfirm={handleGuardrailSelectionConfirm}
onCancel={handleGuardrailSelectionCancel}
isLoading={isCreatingGuardrails}
progressInfo={templateQueueProgress}
/>
<TemplateParameterModal
visible={isParameterModalOpen}
template={pendingTemplate}
onConfirm={handleParameterConfirm}
onCancel={handleParameterCancel}
isLoading={isEnrichingTemplate}
accessToken={accessToken || ""}
/>
<AiSuggestionModal <AiSuggestionModal
visible={isAiSuggestionModalOpen} visible={isAiSuggestionModalOpen}
onSelectTemplates={(selectedTemplates) => { onSelectTemplates={(selectedTemplates) => {

View file

@ -0,0 +1,169 @@
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "@/../tests/test-utils";
import PipelineFlowBuilder, { PipelineInfoDisplay } from "./pipeline_flow_builder";
import { GuardrailPipeline, PipelineStep } from "@/components/policies/types";
import { Guardrail } from "@/components/guardrails/types";
vi.mock("@/components/networking");
const step = (overrides: Partial<PipelineStep> = {}): PipelineStep => ({
guardrail: "pii-masker",
on_pass: "next",
on_fail: "block",
on_error: null,
modify_response_message: null,
...overrides,
});
const pipeline = (steps: PipelineStep[]): GuardrailPipeline => ({ mode: "pre_call", steps });
const guardrails = [
{ guardrail_id: "g1", guardrail_name: "pii-masker" },
{ guardrail_id: "g2", guardrail_name: "prompt-injection" },
] as Guardrail[];
describe("PipelineInfoDisplay", () => {
it("renders the trigger card", () => {
renderWithProviders(<PipelineInfoDisplay pipeline={pipeline([step()])} />);
expect(screen.getByText("TRIGGER")).toBeInTheDocument();
expect(screen.getByText("Incoming LLM Request")).toBeInTheDocument();
});
it("renders one numbered card per step, naming its guardrail", () => {
renderWithProviders(<PipelineInfoDisplay pipeline={pipeline([step(), step({ guardrail: "prompt-injection" })])} />);
expect(screen.getByText("Step 1")).toBeInTheDocument();
expect(screen.getByText("Step 2")).toBeInTheDocument();
expect(screen.getByText("pii-masker")).toBeInTheDocument();
expect(screen.getByText("prompt-injection")).toBeInTheDocument();
expect(screen.getAllByText("GUARDRAIL")).toHaveLength(2);
});
it("maps raw action values to their human labels", () => {
renderWithProviders(<PipelineInfoDisplay pipeline={pipeline([step({ on_pass: "next", on_fail: "block" })])} />);
expect(screen.getByText(/Pass .* Next Step/)).toBeInTheDocument();
expect(screen.getByText(/On fail .* Block/)).toBeInTheDocument();
});
it("falls back to the on-fail action when no API-failure action is set", () => {
renderWithProviders(<PipelineInfoDisplay pipeline={pipeline([step({ on_fail: "block", on_error: null })])} />);
expect(screen.getByText(/On API failure .* Block \(same as on fail\)/)).toBeInTheDocument();
});
it("shows an explicit API-failure action when one is set", () => {
renderWithProviders(<PipelineInfoDisplay pipeline={pipeline([step({ on_error: "allow" })])} />);
expect(screen.getByText(/On API failure .* Allow/)).toBeInTheDocument();
expect(screen.queryByText(/same as on fail/)).not.toBeInTheDocument();
});
});
describe("PipelineFlowBuilder", () => {
it("renders the trigger and end cards around the steps", () => {
renderWithProviders(
<PipelineFlowBuilder pipeline={pipeline([step()])} onChange={vi.fn()} availableGuardrails={guardrails} />,
);
expect(screen.getByText("TRIGGER")).toBeInTheDocument();
expect(screen.getByText("END")).toBeInTheDocument();
expect(screen.getByText("Continue to LLM")).toBeInTheDocument();
});
it("labels each decision section of a step", () => {
renderWithProviders(
<PipelineFlowBuilder pipeline={pipeline([step()])} onChange={vi.fn()} availableGuardrails={guardrails} />,
);
expect(screen.getByText("ON PASS")).toBeInTheDocument();
expect(screen.getByText("ON FAIL")).toBeInTheDocument();
expect(screen.getByText("ON API FAILURE")).toBeInTheDocument();
});
it("inserts a step at the clicked connector", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<PipelineFlowBuilder pipeline={pipeline([step()])} onChange={onChange} availableGuardrails={guardrails} />,
);
await user.click(screen.getAllByRole("button", { name: "Insert step" })[0]);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange.mock.calls[0][0].steps).toHaveLength(2);
});
it("removes the clicked step when more than one exists", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<PipelineFlowBuilder
pipeline={pipeline([step(), step({ guardrail: "prompt-injection" })])}
onChange={onChange}
availableGuardrails={guardrails}
/>,
);
await user.click(screen.getAllByRole("button", { name: "Delete step" })[0]);
expect(onChange.mock.calls[0][0].steps).toHaveLength(1);
expect(onChange.mock.calls[0][0].steps[0].guardrail).toBe("prompt-injection");
});
it("disables deletion of the only remaining step", () => {
renderWithProviders(
<PipelineFlowBuilder pipeline={pipeline([step()])} onChange={vi.fn()} availableGuardrails={guardrails} />,
);
expect(screen.getByRole("button", { name: "Delete step" })).toBeDisabled();
});
it("offers a custom response field only when the action is modify_response", () => {
const { rerender } = renderWithProviders(
<PipelineFlowBuilder pipeline={pipeline([step()])} onChange={vi.fn()} availableGuardrails={guardrails} />,
);
expect(screen.queryByPlaceholderText("Enter custom response...")).not.toBeInTheDocument();
rerender(
<PipelineFlowBuilder
pipeline={pipeline([step({ on_fail: "modify_response" })])}
onChange={vi.fn()}
availableGuardrails={guardrails}
/>,
);
expect(screen.getByPlaceholderText("Enter custom response...")).toBeInTheDocument();
});
it("reports an edited custom response message", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<PipelineFlowBuilder
pipeline={pipeline([step({ on_fail: "modify_response" })])}
onChange={onChange}
availableGuardrails={guardrails}
/>,
);
await user.type(screen.getByPlaceholderText("Enter custom response..."), "x");
expect(onChange.mock.calls[0][0].steps[0].modify_response_message).toBe("x");
});
it("offers a guardrail picker for the step", () => {
renderWithProviders(
<PipelineFlowBuilder pipeline={pipeline([step()])} onChange={vi.fn()} availableGuardrails={guardrails} />,
);
// Which control surfaces the selection is a presentation detail; that the step's
// guardrail is the one displayed is covered by the PipelineInfoDisplay tests above.
expect(screen.getByText("Guardrail")).toBeInTheDocument();
expect(screen.getAllByRole("combobox").length).toBeGreaterThan(0);
});
});

View file

@ -1,9 +1,11 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Select, Typography, Spin } from "antd";
import MessageManager from "@/components/molecules/message_manager"; import MessageManager from "@/components/molecules/message_manager";
import { Button, TextInput } from "@tremor/react"; import { Button } from "@/components/ui/button";
import { ArrowLeftIcon, PlusIcon } from "@heroicons/react/outline"; import { Input } from "@/components/ui/input";
import { DotsVerticalIcon } from "@heroicons/react/solid"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { ArrowLeft, MoreVertical, Plus } from "lucide-react";
import { import {
GuardrailPipeline, GuardrailPipeline,
PipelineStep, PipelineStep,
@ -33,8 +35,6 @@ function getPromptsForTestSource(source: string): CompliancePrompt[] {
return fw ? fw.categories.flatMap((c) => c.prompts) : []; return fw ? fw.categories.flatMap((c) => c.prompts) : [];
} }
const { Text } = Typography;
const ACTION_OPTIONS = [ const ACTION_OPTIONS = [
{ label: "Next Step", value: "next" }, { label: "Next Step", value: "next" },
{ label: "Allow", value: "allow" }, { label: "Allow", value: "allow" },
@ -241,7 +241,7 @@ const Connector: React.FC<ConnectorProps> = ({ onInsert }) => (
}} }}
title="Insert step" title="Insert step"
> >
<PlusIcon style={{ width: 12, height: 12, color: "#9ca3af" }} /> <Plus style={{ width: 12, height: 12, color: "#9ca3af" }} />
</button> </button>
<div style={{ width: 1, flex: 1, backgroundColor: "#d1d5db" }} /> <div style={{ width: 1, flex: 1, backgroundColor: "#d1d5db" }} />
</div> </div>
@ -316,7 +316,7 @@ const StepCard: React.FC<StepCardProps> = ({
}} }}
title="Delete step" title="Delete step"
> >
<DotsVerticalIcon style={{ width: 16, height: 16, color: "#9ca3af" }} /> <MoreVertical style={{ width: 16, height: 16, color: "#9ca3af" }} />
</button> </button>
</div> </div>
</div> </div>
@ -326,14 +326,12 @@ const StepCard: React.FC<StepCardProps> = ({
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}> <label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Guardrail Guardrail
</label> </label>
<Select <SearchSelect
showSearch
style={{ width: "100%" }}
placeholder="Select a guardrail"
value={step.guardrail || undefined}
onChange={(value) => onChange({ guardrail: value })}
options={guardrailOptions} options={guardrailOptions}
filterOption={(input, option) => (option?.label ?? "").toString().toLowerCase().includes(input.toLowerCase())} value={step.guardrail || undefined}
onValueChange={(value) => onChange({ guardrail: value })}
placeholder="Select a guardrail"
emptyText="No guardrails found"
/> />
</div> </div>
@ -346,18 +344,24 @@ const StepCard: React.FC<StepCardProps> = ({
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}> <label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Action Action
</label> </label>
<Select <Select value={step.on_pass} onValueChange={(value) => onChange({ on_pass: value as PipelineStep["on_pass"] })}>
style={{ width: "100%" }} <SelectTrigger className="w-full">
value={step.on_pass} <SelectValue>{ACTION_LABELS[step.on_pass] || step.on_pass}</SelectValue>
onChange={(value) => onChange({ on_pass: value as PipelineStep["on_pass"] })} </SelectTrigger>
options={ACTION_OPTIONS} <SelectContent>
/> {ACTION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{step.on_pass === "modify_response" && ( {step.on_pass === "modify_response" && (
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}> <label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Custom Response Message Custom Response Message
</label> </label>
<TextInput <Input
placeholder="Enter custom response..." placeholder="Enter custom response..."
value={step.modify_response_message || ""} value={step.modify_response_message || ""}
onChange={(e) => onChange({ modify_response_message: e.target.value || null })} onChange={(e) => onChange({ modify_response_message: e.target.value || null })}
@ -375,18 +379,24 @@ const StepCard: React.FC<StepCardProps> = ({
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}> <label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Action Action
</label> </label>
<Select <Select value={step.on_fail} onValueChange={(value) => onChange({ on_fail: value as PipelineStep["on_fail"] })}>
style={{ width: "100%" }} <SelectTrigger className="w-full">
value={step.on_fail} <SelectValue>{ACTION_LABELS[step.on_fail] || step.on_fail}</SelectValue>
onChange={(value) => onChange({ on_fail: value as PipelineStep["on_fail"] })} </SelectTrigger>
options={ACTION_OPTIONS} <SelectContent>
/> {ACTION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{step.on_fail === "modify_response" && ( {step.on_fail === "modify_response" && (
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}> <label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Custom Response Message Custom Response Message
</label> </label>
<TextInput <Input
placeholder="Enter custom response..." placeholder="Enter custom response..."
value={step.modify_response_message || ""} value={step.modify_response_message || ""}
onChange={(e) => onChange({ modify_response_message: e.target.value || null })} onChange={(e) => onChange({ modify_response_message: e.target.value || null })}
@ -405,23 +415,31 @@ const StepCard: React.FC<StepCardProps> = ({
Action Action
</label> </label>
<Select <Select
style={{ width: "100%" }} value={step.on_error ?? null}
placeholder="Same as ON FAIL" onValueChange={(value) =>
allowClear onChange({ on_error: value === null ? undefined : (value as PipelineStep["on_error"]) })
value={step.on_error ?? undefined}
onChange={(value) =>
onChange({
on_error: value === undefined || value === null ? undefined : (value as PipelineStep["on_error"]),
})
} }
options={ACTION_OPTIONS} >
/> <SelectTrigger className="w-full">
<SelectValue>
{step.on_error != null ? ACTION_LABELS[step.on_error] || step.on_error : "Same as ON FAIL"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={null}>Same as ON FAIL</SelectItem>
{ACTION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{step.on_error === "modify_response" && step.on_fail !== "modify_response" && ( {step.on_error === "modify_response" && step.on_fail !== "modify_response" && (
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}> <label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Custom Response Message Custom Response Message
</label> </label>
<TextInput <Input
placeholder="Enter custom response..." placeholder="Enter custom response..."
value={step.modify_response_message || ""} value={step.modify_response_message || ""}
onChange={(e) => onChange({ modify_response_message: e.target.value || null })} onChange={(e) => onChange({ modify_response_message: e.target.value || null })}
@ -823,13 +841,20 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({ pipeline, accessT
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}> <label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Test with Test with
</label> </label>
<Select <Select value={testSource} onValueChange={(value) => value !== null && setTestSource(value)}>
value={testSource} <SelectTrigger className="mb-3 w-full">
onChange={setTestSource} <SelectValue>
options={testSourceOptions} {testSourceOptions.find((option) => option.value === testSource)?.label ?? testSource}
style={{ width: "100%", marginBottom: 12 }} </SelectValue>
size="middle" </SelectTrigger>
/> <SelectContent>
{testSourceOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{isQuickChat && ( {isQuickChat && (
<> <>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}> <label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
@ -868,7 +893,7 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({ pipeline, accessT
: `Run pipeline against ${promptsForSource.length} prompts from "${testSource}".`} : `Run pipeline against ${promptsForSource.length} prompts from "${testSource}".`}
</div> </div>
)} )}
<Button onClick={handleRunTest} loading={isRunning} style={{ marginTop: 8, width: "100%" }}> <Button onClick={handleRunTest} disabled={isRunning} style={{ marginTop: 8, width: "100%" }}>
Run Test Run Test
</Button> </Button>
</div> </div>
@ -1157,14 +1182,13 @@ const PolicyVersionsSidebar: React.FC<PolicyVersionsSidebarProps> = ({
<Button <Button
onClick={onNewVersion} onClick={onNewVersion}
disabled={!accessToken || isCreatingVersion} disabled={!accessToken || isCreatingVersion}
loading={isCreatingVersion}
style={{ width: "100%", marginBottom: 12 }} style={{ width: "100%", marginBottom: 12 }}
> >
+ New Version + New Version
</Button> </Button>
{isLoading ? ( {isLoading ? (
<div style={{ display: "flex", justifyContent: "center", padding: 16 }}> <div style={{ display: "flex", justifyContent: "center", padding: 16 }}>
<Spin size="small" /> <UiLoadingSpinner className="size-4" />
</div> </div>
) : versions.length === 0 ? ( ) : versions.length === 0 ? (
<span style={{ fontSize: 13, color: "#9ca3af" }}>No versions found</span> <span style={{ fontSize: 13, color: "#9ca3af" }}>No versions found</span>
@ -1219,7 +1243,6 @@ const PolicyVersionsSidebar: React.FC<PolicyVersionsSidebarProps> = ({
variant="secondary" variant="secondary"
onClick={onPublish} onClick={onPublish}
disabled={!accessToken || isUpdatingStatus} disabled={!accessToken || isUpdatingStatus}
loading={isUpdatingStatus}
style={{ width: "100%", marginBottom: 8 }} style={{ width: "100%", marginBottom: 8 }}
> >
Publish Publish
@ -1242,7 +1265,6 @@ const PolicyVersionsSidebar: React.FC<PolicyVersionsSidebarProps> = ({
<Button <Button
onClick={onPromoteToProduction} onClick={onPromoteToProduction}
disabled={!accessToken || isUpdatingStatus} disabled={!accessToken || isUpdatingStatus}
loading={isUpdatingStatus}
style={{ width: "100%", marginBottom: 8 }} style={{ width: "100%", marginBottom: 8 }}
> >
Promote to production Promote to production
@ -1532,11 +1554,11 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
alignItems: "center", alignItems: "center",
}} }}
> >
<ArrowLeftIcon style={{ width: 18, height: 18, color: "#6b7280" }} /> <ArrowLeft style={{ width: 18, height: 18, color: "#6b7280" }} />
</button> </button>
<span style={{ fontSize: 14, color: "#6b7280" }}>Policies</span> <span style={{ fontSize: 14, color: "#6b7280" }}>Policies</span>
<span style={{ fontSize: 14, color: "#d1d5db" }}>/</span> <span style={{ fontSize: 14, color: "#d1d5db" }}>/</span>
<TextInput <Input
placeholder="Policy name..." placeholder="Policy name..."
value={policyName} value={policyName}
onChange={(e) => setPolicyName(e.target.value)} onChange={(e) => setPolicyName(e.target.value)}
@ -1564,7 +1586,7 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
<Button variant="secondary" onClick={() => setShowTestPanel(!showTestPanel)}> <Button variant="secondary" onClick={() => setShowTestPanel(!showTestPanel)}>
{showTestPanel ? "Hide Test" : "Test Pipeline"} {showTestPanel ? "Hide Test" : "Test Pipeline"}
</Button> </Button>
<Button onClick={handleSave} loading={isSubmitting}> <Button onClick={handleSave} disabled={isSubmitting}>
{isEditing ? "Update Policy" : "Save Policy"} {isEditing ? "Update Policy" : "Save Policy"}
</Button> </Button>
</div> </div>
@ -1579,7 +1601,7 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
flexShrink: 0, flexShrink: 0,
}} }}
> >
<TextInput <Input
placeholder="Add a description (optional)..." placeholder="Add a description (optional)..."
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}

View file

@ -1,13 +1,15 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { Card, Badge, Button } from "@tremor/react"; import { ArrowLeft, Info, Pencil } from "lucide-react";
import { ArrowLeftIcon, PencilIcon } from "@heroicons/react/outline"; import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { Descriptions, Tag, Spin, Divider, Typography, Alert } from "antd"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Policy } from "@/components/policies/types"; import { Policy } from "@/components/policies/types";
import { PipelineInfoDisplay } from "./pipeline_flow_builder"; import { PipelineInfoDisplay } from "./pipeline_flow_builder";
import { getResolvedGuardrails } from "@/components/networking"; import { getResolvedGuardrails } from "@/components/networking";
const { Title, Text } = Typography;
interface PolicyInfoViewProps { interface PolicyInfoViewProps {
policyId: string; policyId: string;
onClose: () => void; onClose: () => void;
@ -17,6 +19,29 @@ interface PolicyInfoViewProps {
getPolicy: (accessToken: string, policyId: string) => Promise<any>; getPolicy: (accessToken: string, policyId: string) => Promise<any>;
} }
interface DetailRowProps {
label: string;
children: React.ReactNode;
}
const DetailRow = ({ label, children }: DetailRowProps) => (
<div className="grid grid-cols-1 border-b border-border last:border-b-0 sm:grid-cols-[200px_minmax(0,1fr)]">
<dt className="bg-muted/50 px-4 py-3 text-sm font-medium">{label}</dt>
<dd className="px-4 py-3 text-sm">{children}</dd>
</div>
);
const SectionHeading = ({ children }: { children: React.ReactNode }) => (
<div className="flex items-center gap-3">
<span className="text-sm font-semibold">{children}</span>
<Separator className="flex-1" />
</div>
);
const Muted = ({ children }: { children: React.ReactNode }) => (
<span className="text-muted-foreground">{children}</span>
);
const PolicyInfoView: React.FC<PolicyInfoViewProps> = ({ const PolicyInfoView: React.FC<PolicyInfoViewProps> = ({
policyId, policyId,
onClose, onClose,
@ -61,8 +86,9 @@ const PolicyInfoView: React.FC<PolicyInfoViewProps> = ({
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex justify-center items-center p-12"> <div className="flex flex-col items-center gap-3 p-12">
<Spin size="large" /> <Skeleton className="h-8 w-64" />
<Skeleton className="h-40 w-full max-w-2xl" />
</div> </div>
); );
} }
@ -70,144 +96,130 @@ const PolicyInfoView: React.FC<PolicyInfoViewProps> = ({
if (!policy) { if (!policy) {
return ( return (
<Card> <Card>
<Text type="danger">Policy not found</Text> <CardContent>
<br /> <p className="text-destructive">Policy not found</p>
<Button onClick={onClose} className="mt-4"> <Button variant="secondary" onClick={onClose} className="mt-4">
Go Back Go Back
</Button> </Button>
</CardContent>
</Card> </Card>
); );
} }
return ( return (
<Card> <Card>
<CardContent>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-between items-center"> <div className="flex items-center justify-between">
<Button variant="secondary" icon={ArrowLeftIcon} onClick={onClose}> <Button variant="secondary" onClick={onClose}>
<ArrowLeft />
Back to Policies Back to Policies
</Button> </Button>
{isAdmin && ( {isAdmin && (
<Button icon={PencilIcon} onClick={() => onEdit(policy)}> <Button onClick={() => onEdit(policy)}>
<Pencil />
Edit Policy Edit Policy
</Button> </Button>
)} )}
</div> </div>
<Title level={4}>{policy.policy_name}</Title> <h4 className="text-lg font-semibold">{policy.policy_name}</h4>
<Descriptions bordered column={1}> <dl className="rounded-md border border-border">
<Descriptions.Item label="Policy ID"> <DetailRow label="Policy ID">
<code className="text-xs bg-gray-100 px-2 py-1 rounded-sm">{policy.policy_id}</code> <code className="rounded-sm bg-muted px-2 py-1 text-xs">{policy.policy_id}</code>
</Descriptions.Item> </DetailRow>
<Descriptions.Item label="Description"> <DetailRow label="Description">{policy.description || <Muted>No description</Muted>}</DetailRow>
{policy.description || <Text type="secondary">No description</Text>} <DetailRow label="Inherits From">
</Descriptions.Item> {policy.inherit ? <Badge variant="secondary">{policy.inherit}</Badge> : <Muted>None</Muted>}
<Descriptions.Item label="Inherits From"> </DetailRow>
{policy.inherit ? ( <DetailRow label="Created At">
<Badge color="blue" size="sm">
{policy.inherit}
</Badge>
) : (
<Text type="secondary">None</Text>
)}
</Descriptions.Item>
<Descriptions.Item label="Created At">
{policy.created_at ? new Date(policy.created_at).toLocaleString() : "-"} {policy.created_at ? new Date(policy.created_at).toLocaleString() : "-"}
</Descriptions.Item> </DetailRow>
<Descriptions.Item label="Updated At"> <DetailRow label="Updated At">
{policy.updated_at ? new Date(policy.updated_at).toLocaleString() : "-"} {policy.updated_at ? new Date(policy.updated_at).toLocaleString() : "-"}
</Descriptions.Item> </DetailRow>
</Descriptions> </dl>
{policy.pipeline && ( {policy.pipeline && (
<> <>
<Divider orientation="left"> <SectionHeading>Pipeline Flow</SectionHeading>
<Text strong>Pipeline Flow</Text> <Alert className="mb-4">
</Divider> <Info />
<Alert <AlertTitle>
message={`Pipeline (${policy.pipeline.mode} mode, ${policy.pipeline.steps.length} step${policy.pipeline.steps.length !== 1 ? "s" : ""})`} Pipeline ({policy.pipeline.mode} mode, {policy.pipeline.steps.length} step
type="info" {policy.pipeline.steps.length !== 1 ? "s" : ""})
showIcon </AlertTitle>
style={{ marginBottom: 16 }} </Alert>
/>
<PipelineInfoDisplay pipeline={policy.pipeline} /> <PipelineInfoDisplay pipeline={policy.pipeline} />
</> </>
)} )}
<Divider orientation="left"> <SectionHeading>Guardrails Configuration</SectionHeading>
<Text strong>Guardrails Configuration</Text>
</Divider>
{resolvedGuardrails.length > 0 && ( {resolvedGuardrails.length > 0 && (
<Alert <Alert className="mb-4">
message="Resolved Guardrails" <Info />
description={ <AlertTitle>Resolved Guardrails</AlertTitle>
<div> <AlertDescription>
<Text type="secondary" style={{ display: "block", marginBottom: 8 }}> <span className="mb-2 block">Final guardrails that will be applied (including inheritance):</span>
Final guardrails that will be applied (including inheritance):
</Text>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{resolvedGuardrails.map((g) => ( {resolvedGuardrails.map((g) => (
<Tag key={g} color="blue"> <Badge key={g} variant="secondary">
{g} {g}
</Tag> </Badge>
))} ))}
</div> </div>
</div> </AlertDescription>
} </Alert>
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
)} )}
<Descriptions bordered column={1}> <dl className="rounded-md border border-border">
<Descriptions.Item label="Guardrails to Add"> <DetailRow label="Guardrails to Add">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{policy.guardrails_add && policy.guardrails_add.length > 0 ? ( {policy.guardrails_add && policy.guardrails_add.length > 0 ? (
policy.guardrails_add.map((g) => ( policy.guardrails_add.map((g) => (
<Tag key={g} color="green"> <Badge key={g} variant="secondary">
{g} {g}
</Tag> </Badge>
)) ))
) : ( ) : (
<Text type="secondary">None</Text> <Muted>None</Muted>
)} )}
</div> </div>
</Descriptions.Item> </DetailRow>
<Descriptions.Item label="Guardrails to Remove"> <DetailRow label="Guardrails to Remove">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{policy.guardrails_remove && policy.guardrails_remove.length > 0 ? ( {policy.guardrails_remove && policy.guardrails_remove.length > 0 ? (
policy.guardrails_remove.map((g) => ( policy.guardrails_remove.map((g) => (
<Tag key={g} color="red"> <Badge key={g} variant="destructive">
{g} {g}
</Tag> </Badge>
)) ))
) : ( ) : (
<Text type="secondary">None</Text> <Muted>None</Muted>
)} )}
</div> </div>
</Descriptions.Item> </DetailRow>
</Descriptions> </dl>
<Divider orientation="left"> <SectionHeading>Conditions</SectionHeading>
<Text strong>Conditions</Text>
</Divider>
<Descriptions bordered column={1}> <dl className="rounded-md border border-border">
<Descriptions.Item label="Model Condition"> <DetailRow label="Model Condition">
{policy.condition?.model ? ( {policy.condition?.model ? (
<Tag color="purple"> <Badge variant="secondary">
{typeof policy.condition.model === "string" {typeof policy.condition.model === "string"
? policy.condition.model ? policy.condition.model
: JSON.stringify(policy.condition.model)} : JSON.stringify(policy.condition.model)}
</Tag> </Badge>
) : ( ) : (
<Text type="secondary">No model condition (applies to all models)</Text> <Muted>No model condition (applies to all models)</Muted>
)} )}
</Descriptions.Item> </DetailRow>
</Descriptions> </dl>
</div> </div>
</CardContent>
</Card> </Card>
); );
}; };

View file

@ -1,19 +1,17 @@
import React, { useState, useEffect, useMemo } from "react"; import React, { useState, useEffect, useMemo } from "react";
import { Card, Button, Spin, Checkbox } from "antd"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Skeleton } from "@/components/ui/skeleton";
import MessageManager from "@/components/molecules/message_manager"; import MessageManager from "@/components/molecules/message_manager";
import { import { ShieldCheck, ShieldAlert, FlaskConical, CircleDollarSign, CheckCircle2 } from "lucide-react";
ShieldCheckIcon,
ShieldExclamationIcon,
BeakerIcon,
CurrencyDollarIcon,
CheckCircleIcon,
} from "@heroicons/react/outline";
import { getPolicyTemplates } from "@/components/networking"; import { getPolicyTemplates } from "@/components/networking";
interface PolicyTemplateCardProps { interface PolicyTemplateCardProps {
title: string; title: string;
description: string; description: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; icon: React.ComponentType<{ className?: string }>;
iconColor: string; iconColor: string;
iconBg: string; iconBg: string;
guardrails: string[]; guardrails: string[];
@ -35,73 +33,53 @@ const PolicyTemplateCard: React.FC<PolicyTemplateCardProps> = ({
complexity, complexity,
onUseTemplate, onUseTemplate,
}) => { }) => {
const getComplexityStyle = () => {
switch (complexity) {
case "Low":
return "bg-gray-50 text-gray-600 border-gray-200";
case "Medium":
return "bg-blue-50 text-blue-600 border-blue-100";
case "High":
return "bg-purple-50 text-purple-600 border-purple-100";
}
};
return ( return (
<Card <Card className="h-full transition-shadow hover:shadow-md">
className="h-full hover:shadow-md transition-shadow" <CardContent className="flex h-full flex-col">
bodyStyle={{ display: "flex", flexDirection: "column", height: "100%" }} <div className="mb-4 flex items-start justify-between">
> <div className={`rounded-lg p-2 ${iconBg}`}>
<div className="flex items-start justify-between mb-4"> <Icon className={`size-6 ${iconColor}`} />
<div className={`p-2 rounded-lg ${iconBg}`}>
<Icon className={`h-6 w-6 ${iconColor}`} />
</div> </div>
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${getComplexityStyle()}`}> <Badge variant="outline">{complexity} Complexity</Badge>
{complexity} Complexity
</span>
</div> </div>
<h3 className="text-base font-semibold text-gray-900 mb-2">{title}</h3> <h3 className="mb-2 text-base font-semibold">{title}</h3>
<p className="text-sm text-gray-500 mb-4 grow">{description}</p> <p className="mb-4 grow text-sm text-muted-foreground">{description}</p>
{tags.length > 0 && ( {tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-4"> <div className="mb-4 flex flex-wrap gap-1.5">
{tags.map((tag) => ( {tags.map((tag) => (
<span <Badge key={tag} variant="secondary">
key={tag}
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 border border-blue-100"
>
{tag} {tag}
</span> </Badge>
))} ))}
</div> </div>
)} )}
{inherits && ( {inherits && (
<div className="mb-4 text-xs"> <div className="mb-4 text-xs">
<span className="text-gray-500">Inherits from: </span> <span className="text-muted-foreground">Inherits from: </span>
<span className="font-medium text-gray-700 bg-gray-100 px-2 py-0.5 rounded-sm">{inherits}</span> <span className="rounded-sm bg-muted px-2 py-0.5 font-medium">{inherits}</span>
</div> </div>
)} )}
<div className="mb-6"> <div className="mb-6">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider block mb-2"> <span className="mb-2 block text-xs font-medium tracking-wider text-muted-foreground uppercase">
Included Guardrails Included Guardrails
</span> </span>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{guardrails.map((g) => ( {guardrails.map((g) => (
<span <Badge key={g} variant="outline">
key={g}
className="inline-flex items-center px-2 py-1 rounded-sm text-xs font-medium bg-gray-50 text-gray-700 border border-gray-200"
>
{g} {g}
</span> </Badge>
))} ))}
</div> </div>
</div> </div>
<Button type="primary" block className="mt-auto" onClick={onUseTemplate}> <Button className="mt-auto w-full" onClick={onUseTemplate}>
Use Template Use Template
</Button> </Button>
</CardContent>
</Card> </Card>
); );
}; };
@ -114,12 +92,12 @@ interface PolicyTemplatesProps {
} }
// Map icon names from JSON to actual icon components // Map icon names from JSON to actual icon components
const iconMap: Record<string, React.ComponentType<React.SVGProps<SVGSVGElement>>> = { const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
ShieldCheckIcon: ShieldCheckIcon, ShieldCheckIcon: ShieldCheck,
ShieldExclamationIcon: ShieldExclamationIcon, ShieldExclamationIcon: ShieldAlert,
BeakerIcon: BeakerIcon, BeakerIcon: FlaskConical,
CurrencyDollarIcon: CurrencyDollarIcon, CurrencyDollarIcon: CircleDollarSign,
CheckCircleIcon: CheckCircleIcon, CheckCircleIcon: CheckCircle2,
}; };
const PolicyTemplates: React.FC<PolicyTemplatesProps> = ({ const PolicyTemplates: React.FC<PolicyTemplatesProps> = ({
@ -192,8 +170,10 @@ const PolicyTemplates: React.FC<PolicyTemplatesProps> = ({
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex justify-center items-center py-20"> <div className="grid grid-cols-1 gap-6 py-20 md:grid-cols-2 xl:grid-cols-3">
<Spin size="large" tip="Loading policy templates..." /> <Skeleton className="h-72 w-full" />
<Skeleton className="h-72 w-full" />
<Skeleton className="h-72 w-full" />
</div> </div>
); );
} }
@ -202,12 +182,12 @@ const PolicyTemplates: React.FC<PolicyTemplatesProps> = ({
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-between items-end"> <div className="flex justify-between items-end">
<div> <div>
<h2 className="text-lg font-medium text-gray-900">Policy Templates</h2> <h2 className="text-lg font-medium">Policy Templates</h2>
<p className="text-sm text-gray-500 mt-1"> <p className="mt-1 text-sm text-muted-foreground">
Start with a pre-configured policy template to quickly set up guardrails for your organization. Start with a pre-configured policy template to quickly set up guardrails for your organization.
</p> </p>
</div> </div>
<Button type="default" onClick={onOpenAiSuggestion} className="flex items-center gap-1.5"> <Button variant="outline" onClick={onOpenAiSuggestion}>
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="currentColor"> <svg className="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1l1.5 3.5L13 6l-3.5 1.5L8 11 6.5 7.5 3 6l3.5-1.5L8 1zm4 7l.75 1.75L14.5 10.5l-1.75.75L12 13l-.75-1.75L9.5 10.5l1.75-.75L12 8zM4 9l.75 1.75L6.5 11.5l-1.75.75L4 14l-.75-1.75L1.5 11.5l1.75-.75L4 9z" /> <path d="M8 1l1.5 3.5L13 6l-3.5 1.5L8 11 6.5 7.5 3 6l3.5-1.5L8 1zm4 7l.75 1.75L14.5 10.5l-1.75.75L12 13l-.75-1.75L9.5 10.5l1.75-.75L12 8zM4 9l.75 1.75L6.5 11.5l-1.75.75L4 14l-.75-1.75L1.5 11.5l1.75-.75L4 9z" />
</svg> </svg>
@ -221,9 +201,9 @@ const PolicyTemplates: React.FC<PolicyTemplatesProps> = ({
<div className="w-52 shrink-0"> <div className="w-52 shrink-0">
<div className="sticky top-4"> <div className="sticky top-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold text-gray-900">Categories</span> <span className="text-sm font-semibold">Categories</span>
{selectedTags.size > 0 && ( {selectedTags.size > 0 && (
<button onClick={handleClearAll} className="text-xs text-blue-600 hover:text-blue-800"> <button onClick={handleClearAll} className="text-xs text-primary hover:underline">
Clear all Clear all
</button> </button>
)} )}
@ -233,14 +213,14 @@ const PolicyTemplates: React.FC<PolicyTemplatesProps> = ({
<label <label
key={tag} key={tag}
className={`flex items-center justify-between px-2 py-1.5 rounded-md cursor-pointer transition-colors ${ className={`flex items-center justify-between px-2 py-1.5 rounded-md cursor-pointer transition-colors ${
selectedTags.has(tag) ? "bg-blue-50" : "hover:bg-gray-50" selectedTags.has(tag) ? "bg-accent" : "hover:bg-muted"
}`} }`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox checked={selectedTags.has(tag)} onChange={() => handleTagToggle(tag)} /> <Checkbox checked={selectedTags.has(tag)} onCheckedChange={() => handleTagToggle(tag)} />
<span className="text-sm text-gray-700">{tag}</span> <span className="text-sm">{tag}</span>
</div> </div>
<span className="text-xs text-gray-400 font-medium">{count}</span> <span className="text-xs font-medium text-muted-foreground">{count}</span>
</label> </label>
))} ))}
</div> </div>
@ -251,7 +231,7 @@ const PolicyTemplates: React.FC<PolicyTemplatesProps> = ({
{/* Right content - template cards */} {/* Right content - template cards */}
<div className="flex-1"> <div className="flex-1">
{selectedTags.size > 0 && ( {selectedTags.size > 0 && (
<div className="mb-4 text-sm text-gray-500"> <div className="mb-4 text-sm text-muted-foreground">
Showing {filteredTemplates.length} of {templates.length} templates Showing {filteredTemplates.length} of {templates.length} templates
</div> </div>
)} )}
@ -261,7 +241,7 @@ const PolicyTemplates: React.FC<PolicyTemplatesProps> = ({
key={template.id || index} key={template.id || index}
title={template.title} title={template.title}
description={template.description} description={template.description}
icon={iconMap[template.icon] || ShieldCheckIcon} icon={iconMap[template.icon] || ShieldCheck}
iconColor={template.iconColor} iconColor={template.iconColor}
iconBg={template.iconBg} iconBg={template.iconBg}
guardrails={template.guardrails} guardrails={template.guardrails}
@ -274,9 +254,9 @@ const PolicyTemplates: React.FC<PolicyTemplatesProps> = ({
</div> </div>
{filteredTemplates.length === 0 && ( {filteredTemplates.length === 0 && (
<div className="text-center py-12 text-gray-500"> <div className="py-12 text-center text-muted-foreground">
<p>No templates match the selected filters.</p> <p>No templates match the selected filters.</p>
<button onClick={handleClearAll} className="text-blue-600 hover:text-blue-800 mt-2 text-sm"> <button onClick={handleClearAll} className="mt-2 text-sm text-primary hover:underline">
Clear all filters Clear all filters
</button> </button>
</div> </div>

View file

@ -0,0 +1,208 @@
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "@/../tests/test-utils";
import TemplateParameterModal from "./template_parameter_modal";
const { modelHubCall, enrichPolicyTemplateStream } = vi.hoisted(() => ({
modelHubCall: vi.fn(),
enrichPolicyTemplateStream: vi.fn(),
}));
vi.mock("@/components/networking", () => ({ modelHubCall, enrichPolicyTemplateStream }));
type StreamResult = { competitors: string[]; competitor_variations?: Record<string, string[]> };
type StreamArgs = [
token: string,
templateId: string,
params: Record<string, string>,
model: string,
onName: (name: string) => void,
onDone: (result: StreamResult) => void,
];
interface TestTemplate {
id: string;
title: string;
llm_enrichment?: { parameter: string };
parameters: { name: string; label: string; type: string; required: boolean; placeholder?: string }[];
}
const plainTemplate: TestTemplate = {
id: "tpl-plain",
title: "Basic Redaction",
parameters: [
{ name: "org_name", label: "Organization Name", type: "string", required: true, placeholder: "e.g. Contoso" },
{ name: "note", label: "Note", type: "string", required: false, placeholder: "optional note" },
],
};
const enrichmentTemplate: TestTemplate = {
id: "tpl-competitor",
title: "Competitor Blocking",
llm_enrichment: { parameter: "brand_name" },
parameters: [{ name: "brand_name", label: "Your Brand Name", type: "string", required: true }],
};
const defaultProps = {
visible: true,
template: plainTemplate,
onConfirm: vi.fn(),
onCancel: vi.fn(),
accessToken: "sk-test",
};
const renderModal = (props: Partial<typeof defaultProps> = {}) =>
renderWithProviders(<TemplateParameterModal {...defaultProps} {...props} />);
describe("TemplateParameterModal", () => {
beforeEach(() => {
vi.clearAllMocks();
modelHubCall.mockResolvedValue({ data: [{ model_group: "gpt-5.1" }] });
});
it("renders nothing while closed", () => {
renderModal({ visible: false });
expect(screen.queryByText("Basic Redaction")).not.toBeInTheDocument();
});
it("shows the template title and purpose when opened", async () => {
renderModal();
expect(await screen.findByText("Basic Redaction")).toBeInTheDocument();
expect(screen.getByText("Configure competitor blocking for your brand")).toBeInTheDocument();
});
it("renders exactly one labelled field per template parameter", async () => {
renderModal();
// Exactly one: a plain template used to render every parameter twice, once from
// the shared list and again from a duplicate no-enrichment branch.
expect(await screen.findByText("Organization Name")).toBeInTheDocument();
expect(screen.getByText("Note")).toBeInTheDocument();
expect(screen.getAllByPlaceholderText("e.g. Contoso")).toHaveLength(1);
expect(screen.getAllByPlaceholderText("optional note")).toHaveLength(1);
});
it("keeps Continue disabled until every required parameter is filled", async () => {
const user = userEvent.setup();
renderModal();
await screen.findByText("Basic Redaction");
expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled();
await user.type(screen.getByPlaceholderText("e.g. Contoso"), "Contoso");
expect(screen.getByRole("button", { name: "Continue" })).not.toBeDisabled();
});
it("hands the entered parameters back to the caller", async () => {
const onConfirm = vi.fn();
const user = userEvent.setup();
renderModal({ onConfirm });
await screen.findByText("Basic Redaction");
await user.type(screen.getByPlaceholderText("e.g. Contoso"), "Contoso");
await user.click(screen.getByRole("button", { name: "Continue" }));
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onConfirm.mock.calls[0][0]).toEqual({ org_name: "Contoso", note: "" });
});
it("cancels back to the caller", async () => {
const onCancel = vi.fn();
const user = userEvent.setup();
renderModal({ onCancel });
await screen.findByText("Basic Redaction");
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("offers AI discovery controls for an enrichment template", async () => {
renderModal({ template: enrichmentTemplate });
expect(await screen.findByText("Competitor Discovery")).toBeInTheDocument();
expect(screen.getByText("Your Brand Name")).toBeInTheDocument();
expect(screen.getByText("Select Model")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Generate Competitor Names/ })).toBeInTheDocument();
});
it("loads the model list for an enrichment template", async () => {
renderModal({ template: enrichmentTemplate });
await waitFor(() => {
expect(modelHubCall).toHaveBeenCalledWith("sk-test");
});
});
it("hides the model picker when competitors are entered manually", async () => {
const user = userEvent.setup();
renderModal({ template: enrichmentTemplate });
await screen.findByText("Competitor Discovery");
await user.click(screen.getByText("Enter Manually"));
await waitFor(() => {
expect(screen.queryByText("Select Model")).not.toBeInTheDocument();
});
expect(screen.queryByRole("button", { name: /Generate Competitor Names/ })).not.toBeInTheDocument();
});
it("keeps Continue disabled for an enrichment template until competitors exist", async () => {
const user = userEvent.setup();
renderModal({ template: enrichmentTemplate });
await screen.findByText("Competitor Discovery");
await user.type(screen.getByPlaceholderText("e.g. Acme Airlines"), "Contoso");
expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled();
});
it("streams discovered competitor names and enables Continue once they arrive", async () => {
enrichPolicyTemplateStream.mockImplementation(async (...args: StreamArgs) => {
const [, , , , onName, onDone] = args;
onName("Northwind");
onDone({ competitors: ["Northwind", "Fabrikam"], competitor_variations: {} });
});
const user = userEvent.setup();
renderModal({ template: enrichmentTemplate });
await screen.findByText("Competitor Discovery");
await user.type(screen.getByPlaceholderText("e.g. Acme Airlines"), "Contoso");
await user.click(screen.getAllByRole("combobox")[0]);
const options = await screen.findAllByText("gpt-5.1");
await user.click(options[options.length - 1]);
await user.click(screen.getByRole("button", { name: /Generate Competitor Names/ }));
expect(await screen.findByText("Northwind")).toBeInTheDocument();
expect(screen.getByText("Fabrikam")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByRole("button", { name: "Continue" })).not.toBeDisabled();
});
});
it("passes the discovered competitors to the caller on confirm", async () => {
enrichPolicyTemplateStream.mockImplementation(async (...args: StreamArgs) => {
const [, , , , , onDone] = args;
onDone({ competitors: ["Northwind"] });
});
const onConfirm = vi.fn();
const user = userEvent.setup();
renderModal({ template: enrichmentTemplate, onConfirm });
await screen.findByText("Competitor Discovery");
await user.type(screen.getByPlaceholderText("e.g. Acme Airlines"), "Contoso");
await user.click(screen.getAllByRole("combobox")[0]);
const options = await screen.findAllByText("gpt-5.1");
await user.click(options[options.length - 1]);
await user.click(screen.getByRole("button", { name: /Generate Competitor Names/ }));
await screen.findByText("Northwind");
await user.click(screen.getByRole("button", { name: "Continue" }));
expect(onConfirm).toHaveBeenCalledWith({ brand_name: "Contoso" }, { competitors: ["Northwind"] });
});
});

View file

@ -1,6 +1,19 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Modal, Spin, Radio, Select } from "antd"; import { Button } from "@/components/ui/button";
import { Button, TextInput } from "@tremor/react"; import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { X } from "lucide-react";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { modelHubCall, enrichPolicyTemplateStream } from "@/components/networking"; import { modelHubCall, enrichPolicyTemplateStream } from "@/components/networking";
interface TemplateParameter { interface TemplateParameter {
@ -43,6 +56,7 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
const [isRefining, setIsRefining] = useState(false); const [isRefining, setIsRefining] = useState(false);
const [hasGenerated, setHasGenerated] = useState(false); const [hasGenerated, setHasGenerated] = useState(false);
const [statusMessage, setStatusMessage] = useState(""); const [statusMessage, setStatusMessage] = useState("");
const [tagDraft, setTagDraft] = useState("");
const parameters: TemplateParameter[] = template?.parameters || []; const parameters: TemplateParameter[] = template?.parameters || [];
const hasEnrichment = !!template?.llm_enrichment; const hasEnrichment = !!template?.llm_enrichment;
@ -66,6 +80,7 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
setIsRefining(false); setIsRefining(false);
setHasGenerated(false); setHasGenerated(false);
setStatusMessage(""); setStatusMessage("");
setTagDraft("");
} }
}, [visible, template]); }, [visible, template]);
@ -181,38 +196,37 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
? allNonEnrichmentFilled && brandNameFilled && competitorTags.length > 0 ? allNonEnrichmentFilled && brandNameFilled && competitorTags.length > 0
: allNonEnrichmentFilled && brandNameFilled; : allNonEnrichmentFilled && brandNameFilled;
const addCompetitorTags = (raw: string) => {
const additions = raw
.split(",")
.map((name) => name.trim())
.filter((name) => name.length > 0 && !competitorTags.some((t) => t.toLowerCase() === name.toLowerCase()));
if (additions.length > 0) setCompetitorTags([...competitorTags, ...additions]);
setTagDraft("");
};
const handleTagDraftKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addCompetitorTags(tagDraft);
return;
}
if (e.key === "Backspace" && tagDraft === "" && competitorTags.length > 0) {
setCompetitorTags(competitorTags.slice(0, -1));
}
};
const handleConfirm = () => { const handleConfirm = () => {
onConfirm(parameterValues, { competitors: competitorTags }); onConfirm(parameterValues, { competitors: competitorTags });
}; };
return ( const renderParameterField = (param: TemplateParameter) => (
<Modal
title={
<div>
<h3 className="text-lg font-semibold mb-1">{template?.title}</h3>
<p className="text-sm text-gray-500 font-normal">Configure competitor blocking for your brand</p>
</div>
}
open={visible}
onCancel={onCancel}
width={700}
footer={[
<Button key="cancel" variant="secondary" onClick={onCancel} disabled={isLoading}>
Cancel
</Button>,
<Button key="confirm" onClick={handleConfirm} loading={isLoading} disabled={!canContinue || isLoading}>
{isLoading ? "Creating guardrails..." : "Continue"}
</Button>,
]}
>
<div className="py-4 space-y-4">
{nonEnrichmentParams.map((param) => (
<div key={param.name}> <div key={param.name}>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="mb-1 block text-sm font-medium">
{param.label} {param.label}
{param.required && <span className="text-red-500 ml-1">*</span>} {param.required && <span className="ml-1 text-destructive">*</span>}
</label> </label>
<TextInput <Input
placeholder={param.placeholder || ""} placeholder={param.placeholder || ""}
value={parameterValues[param.name] || ""} value={parameterValues[param.name] || ""}
onChange={(e) => onChange={(e) =>
@ -223,35 +237,45 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
} }
/> />
</div> </div>
))} );
return (
<Dialog open={visible} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className="sm:max-w-175">
<DialogHeader>
<DialogTitle className="text-lg">{template?.title}</DialogTitle>
<DialogDescription>Configure competitor blocking for your brand</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{nonEnrichmentParams.map(renderParameterField)}
{hasEnrichment && ( {hasEnrichment && (
<> <>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">Competitor Discovery</label> <label className="mb-2 block text-sm font-medium">Competitor Discovery</label>
<Radio.Group <RadioGroup
value={competitorMode} value={competitorMode}
onChange={(e) => setCompetitorMode(e.target.value)} onValueChange={(value) => setCompetitorMode(value as "ai" | "manual")}
className="w-full" className="grid-cols-2"
> >
<div className="flex gap-3"> <label className="flex cursor-pointer items-center justify-center gap-2 rounded-md border border-input px-3 py-2 text-sm">
<Radio.Button value="ai" className="flex-1 text-center"> <RadioGroupItem value="ai" />✨ Use AI
✨ Use AI </label>
</Radio.Button> <label className="flex cursor-pointer items-center justify-center gap-2 rounded-md border border-input px-3 py-2 text-sm">
<Radio.Button value="manual" className="flex-1 text-center"> <RadioGroupItem value="manual" />
Enter Manually Enter Manually
</Radio.Button> </label>
</div> </RadioGroup>
</Radio.Group>
</div> </div>
{/* Brand Name */} {/* Brand Name */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="mb-1 block text-sm font-medium">
Your Brand Name Your Brand Name
<span className="text-red-500 ml-1">*</span> <span className="ml-1 text-destructive">*</span>
</label> </label>
<TextInput <Input
placeholder="e.g. Acme Airlines" placeholder="e.g. Acme Airlines"
value={parameterValues[enrichmentParam || "brand_name"] || ""} value={parameterValues[enrichmentParam || "brand_name"] || ""}
onChange={(e) => onChange={(e) =>
@ -266,25 +290,22 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
{competitorMode === "ai" && ( {competitorMode === "ai" && (
<> <>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="mb-1 block text-sm font-medium">
Select Model Select Model
<span className="text-red-500 ml-1">*</span> <span className="ml-1 text-destructive">*</span>
</label> </label>
<Select <SearchSelect
placeholder="Select a model to generate names"
value={selectedModel}
onChange={(value) => setSelectedModel(value)}
loading={isLoadingModels}
showSearch
className="w-full"
options={availableModels.map((m) => ({ label: m, value: m }))} options={availableModels.map((m) => ({ label: m, value: m }))}
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())} value={selectedModel}
onValueChange={(value) => setSelectedModel(value || undefined)}
placeholder={isLoadingModels ? "Loading models..." : "Select a model to generate names"}
emptyText="No models found"
disabled={isLoadingModels}
/> />
</div> </div>
<Button <Button
onClick={handleGenerateNames} onClick={handleGenerateNames}
loading={isGenerating}
disabled={!selectedModel || !brandNameFilled || isGenerating} disabled={!selectedModel || !brandNameFilled || isGenerating}
className="w-full" className="w-full"
> >
@ -295,33 +316,46 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
{/* Competitor Tags */} {/* Competitor Tags */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="mb-1 block text-sm font-medium">
Competitor Names Competitor Names
{competitorTags.length > 0 && ( {competitorTags.length > 0 && (
<span className="text-gray-400 font-normal ml-2">({competitorTags.length})</span> <span className="ml-2 font-normal text-muted-foreground">({competitorTags.length})</span>
)} )}
</label> </label>
<Select <div className="flex flex-wrap items-center gap-1.5 rounded-md border border-input p-2">
mode="tags" {competitorTags.map((tag) => (
style={{ width: "100%" }} <Badge key={tag} variant="secondary" className="gap-1">
{tag}
<button
type="button"
aria-label={`Remove ${tag}`}
onClick={() => setCompetitorTags(competitorTags.filter((t) => t !== tag))}
>
<X className="size-3" />
</button>
</Badge>
))}
<input
className="min-w-40 flex-1 bg-transparent text-sm outline-none"
placeholder="Type a name and press Enter to add" placeholder="Type a name and press Enter to add"
value={competitorTags} value={tagDraft}
onChange={(values) => setCompetitorTags(values)} onChange={(e) => setTagDraft(e.target.value)}
tokenSeparators={[","]} onKeyDown={handleTagDraftKeyDown}
open={false}
suffixIcon={null}
/> />
<p className="text-xs text-gray-500 mt-1">Type a name and press Enter to add. Click ✕ to remove.</p> </div>
<p className="mt-1 text-xs text-muted-foreground">
Type a name and press Enter to add. Click ✕ to remove.
</p>
{statusMessage && ( {statusMessage && (
<div className="flex items-center gap-2 mt-2 p-2 bg-blue-50 rounded-sm border border-blue-100"> <div className="mt-2 flex items-center gap-2 rounded-sm border border-border bg-muted p-2">
<Spin size="small" /> <UiLoadingSpinner className="size-3" />
<span className="text-xs text-blue-700">{statusMessage}</span> <span className="text-xs text-muted-foreground">{statusMessage}</span>
</div> </div>
)} )}
{Object.keys(variationsMap).length > 0 && !statusMessage && ( {Object.keys(variationsMap).length > 0 && !statusMessage && (
<p className="text-xs text-green-600 mt-1"> <p className="mt-1 text-xs text-green-600">
✓ {Object.values(variationsMap).flat().length} alternate spellings & variations auto-generated for ✓ {Object.values(variationsMap).flat().length} alternate spellings &amp; variations auto-generated
guardrail matching for guardrail matching
</p> </p>
)} )}
</div> </div>
@ -329,9 +363,9 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
{/* Refinement input — shown after initial generation in AI mode */} {/* Refinement input — shown after initial generation in AI mode */}
{competitorMode === "ai" && hasGenerated && competitorTags.length > 0 && ( {competitorMode === "ai" && hasGenerated && competitorTags.length > 0 && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Refine List</label> <label className="mb-1 block text-sm font-medium">Refine List</label>
<div className="flex gap-2"> <div className="flex gap-2">
<TextInput <Input
placeholder="e.g. add 10 more from Asia, increase to 50 total..." placeholder="e.g. add 10 more from Asia, increase to 50 total..."
value={refinementInput} value={refinementInput}
onChange={(e) => setRefinementInput(e.target.value)} onChange={(e) => setRefinementInput(e.target.value)}
@ -342,44 +376,29 @@ const TemplateParameterModal: React.FC<TemplateParameterModalProps> = ({
}} }}
disabled={isRefining} disabled={isRefining}
/> />
<Button <Button onClick={handleRefine} disabled={!refinementInput.trim() || isRefining} size="sm">
onClick={handleRefine}
loading={isRefining}
disabled={!refinementInput.trim() || isRefining}
size="xs"
>
{isRefining ? "..." : "Send"} {isRefining ? "..." : "Send"}
</Button> </Button>
</div> </div>
<p className="text-xs text-gray-400 mt-1"> <p className="mt-1 text-xs text-muted-foreground">
Give instructions to add, remove, or change competitors. Press Enter to send. Give instructions to add, remove, or change competitors. Press Enter to send.
</p> </p>
</div> </div>
)} )}
</> </>
)} )}
</div>
{!hasEnrichment && <DialogFooter>
parameters.map((param) => ( <Button variant="secondary" onClick={onCancel} disabled={isLoading}>
<div key={param.name}> Cancel
<label className="block text-sm font-medium text-gray-700 mb-1"> </Button>
{param.label} <Button onClick={handleConfirm} disabled={!canContinue || isLoading}>
{param.required && <span className="text-red-500 ml-1">*</span>} {isLoading ? "Creating guardrails..." : "Continue"}
</label> </Button>
<TextInput </DialogFooter>
placeholder={param.placeholder || ""} </DialogContent>
value={parameterValues[param.name] || ""} </Dialog>
onChange={(e) =>
setParameterValues((prev) => ({
...prev,
[param.name]: e.target.value,
}))
}
/>
</div>
))}
</div>
</Modal>
); );
}; };

View file

@ -1,7 +1,9 @@
import { render, screen } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { getClaudeCodePluginsList } from "@/components/networking"; import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking";
import type { Plugin } from "@/components/claude_code_plugins/types";
import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel"; import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel";
@ -12,8 +14,27 @@ vi.mock("@/components/networking", () => ({
vi.mock("./PluginTable", () => ({ vi.mock("./PluginTable", () => ({
__esModule: true, __esModule: true,
default: ({ isLoading }: { isLoading: boolean }) => ( default: ({
<div data-testid="plugin-table">{isLoading ? "table-loading" : "table-loaded"}</div> isLoading,
pluginsList,
onDeleteClick,
}: {
isLoading: boolean;
pluginsList: Plugin[];
onDeleteClick: (pluginName: string, displayName: string) => void;
}) => (
<div data-testid="plugin-table">
{isLoading ? "table-loading" : "table-loaded"}
{pluginsList.map((plugin) => (
<button
key={plugin.id}
data-testid={`row-delete-${plugin.id}`}
onClick={() => onDeleteClick(plugin.name, plugin.name)}
>
row delete
</button>
))}
</div>
), ),
})); }));
@ -21,6 +42,14 @@ vi.mock("./add_plugin_form", () => ({ __esModule: true, default: () => null }));
vi.mock("@/components/claude_code_plugins/skill_detail", () => ({ __esModule: true, default: () => null })); vi.mock("@/components/claude_code_plugins/skill_detail", () => ({ __esModule: true, default: () => null }));
const mockGetClaudeCodePluginsList = vi.mocked(getClaudeCodePluginsList); const mockGetClaudeCodePluginsList = vi.mocked(getClaudeCodePluginsList);
const mockDeleteClaudeCodePlugin = vi.mocked(deleteClaudeCodePlugin);
const skill: Plugin = {
id: "plugin-1",
name: "my-skill",
source: { source: "github", repo: "acme/my-skill" },
enabled: true,
};
describe("ClaudeCodePluginsPanel loading state", () => { describe("ClaudeCodePluginsPanel loading state", () => {
beforeEach(() => { beforeEach(() => {
@ -48,3 +77,48 @@ describe("ClaudeCodePluginsPanel loading state", () => {
expect(mockGetClaudeCodePluginsList).toHaveBeenCalledWith("sk-test", false); expect(mockGetClaudeCodePluginsList).toHaveBeenCalledWith("sk-test", false);
}); });
}); });
describe("ClaudeCodePluginsPanel delete confirmation", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetClaudeCodePluginsList.mockResolvedValue({ plugins: [skill], count: 1 });
});
it("should ask for confirmation before deleting and name the skill", async () => {
const user = userEvent.setup();
render(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Admin" />);
await user.click(await screen.findByTestId("row-delete-plugin-1"));
expect(await screen.findByText(/are you sure you want to delete skill/i)).toBeInTheDocument();
expect(screen.getByText("my-skill")).toBeInTheDocument();
expect(screen.getByText("This action cannot be undone.")).toBeInTheDocument();
expect(mockDeleteClaudeCodePlugin).not.toHaveBeenCalled();
});
it("should delete the skill and refresh the list once confirmed", async () => {
const user = userEvent.setup();
mockDeleteClaudeCodePlugin.mockResolvedValue({});
render(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Admin" />);
await user.click(await screen.findByTestId("row-delete-plugin-1"));
await screen.findByText(/are you sure you want to delete skill/i);
await user.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(mockDeleteClaudeCodePlugin).toHaveBeenCalledWith("sk-test", "my-skill"));
await waitFor(() => expect(mockGetClaudeCodePluginsList).toHaveBeenCalledTimes(2));
await waitFor(() => expect(screen.queryByText(/are you sure you want to delete skill/i)).not.toBeInTheDocument());
});
it("should not delete the skill when the confirmation is cancelled", async () => {
const user = userEvent.setup();
render(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Admin" />);
await user.click(await screen.findByTestId("row-delete-plugin-1"));
await screen.findByText(/are you sure you want to delete skill/i);
await user.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByText(/are you sure you want to delete skill/i)).not.toBeInTheDocument());
expect(mockDeleteClaudeCodePlugin).not.toHaveBeenCalled();
});
});

View file

@ -1,6 +1,14 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Button } from "@tremor/react"; import { Button } from "@/components/ui/button";
import { Modal } from "antd"; import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking"; import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking";
import AddPluginForm from "./add_plugin_form"; import AddPluginForm from "./add_plugin_form";
import PluginTable from "./PluginTable"; import PluginTable from "./PluginTable";
@ -115,20 +123,28 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
/> />
{pluginToDelete && ( {pluginToDelete && (
<Modal <AlertDialog
title="Delete Skill" open
open={pluginToDelete !== null} onOpenChange={(open) => {
onOk={handleDeleteConfirm} if (!open) setPluginToDelete(null);
onCancel={() => setPluginToDelete(null)} }}
confirmLoading={isDeleting}
okText="Delete"
okButtonProps={{ danger: true }}
> >
<p> <AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Skill</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete skill: <strong>{pluginToDelete.displayName}</strong>? Are you sure you want to delete skill: <strong>{pluginToDelete.displayName}</strong>?
</p> </AlertDialogDescription>
<p>This action cannot be undone.</p> <p className="text-sm text-muted-foreground">This action cannot be undone.</p>
</Modal> </AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button variant="destructive" onClick={handleDeleteConfirm} disabled={isDeleting}>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)} )}
</div> </div>
); );

View file

@ -1,6 +1,6 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Icon, Button, Col, Text, Grid } from "@tremor/react"; import { RefreshCw } from "lucide-react";
import { RefreshIcon } from "@heroicons/react/outline"; import { Button } from "@/components/ui/button";
import TagInfoView from "./tag_info"; import TagInfoView from "./tag_info";
import { modelInfoCall } from "@/components/networking"; import { modelInfoCall } from "@/components/networking";
import { tagCreateCall, tagListCall, tagDeleteCall } from "@/components/networking"; import { tagCreateCall, tagListCall, tagDeleteCall } from "@/components/networking";
@ -139,22 +139,18 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
editTag={editTag} editTag={editTag}
/> />
) : ( ) : (
<div className="gap-2 p-8 h-[75vh] w-full mt-2"> <div className="mt-2 h-[75vh] w-full gap-2 p-8">
<div className="flex justify-between mt-2 w-full items-center mb-4"> <div className="mt-2 mb-4 flex w-full items-center justify-between">
<h1>Tag Management</h1> <h1>Tag Management</h1>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{lastRefreshed && <Text>Last Refreshed: {lastRefreshed}</Text>} {lastRefreshed && <p className="text-sm">Last Refreshed: {lastRefreshed}</p>}
<Icon <Button variant="outline" size="icon-sm" aria-label="Refresh tags" onClick={handleRefreshClick}>
icon={RefreshIcon} <RefreshCw />
variant="shadow" </Button>
size="xs"
className="self-center cursor-pointer"
onClick={handleRefreshClick}
/>
</div> </div>
</div> </div>
<Text className="mb-4"> <div className="mb-4 text-sm">
Click on a tag name to view and edit its details. Click on a tag name to view and edit its details.
<p> <p>
You can use tags to restrict the usage of certain LLMs based on tags passed in the request. Read more You can use tags to restrict the usage of certain LLMs based on tags passed in the request. Read more
@ -164,14 +160,14 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
</a> </a>
. .
</p> </p>
</Text> </div>
<Button className="mb-4" onClick={() => setIsCreateModalVisible(true)}> <Button className="mb-4" onClick={() => setIsCreateModalVisible(true)}>
+ Create New Tag + Create New Tag
</Button> </Button>
<Grid numItems={1} className="gap-2 pt-2 pb-2 h-[75vh] w-full mt-2"> <div className="mt-2 grid h-[75vh] w-full grid-cols-1 gap-2 pt-2 pb-2">
<Col numColSpan={1}> <div>
<TagTable <TagTable
data={tags} data={tags}
isLoading={isLoadingTags} isLoading={isLoadingTags}
@ -182,8 +178,8 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
onDelete={handleDelete} onDelete={handleDelete}
onSelectTag={setSelectedTagId} onSelectTag={setSelectedTagId}
/> />
</Col> </div>
</Grid> </div>
{/* Create Tag Modal */} {/* Create Tag Modal */}
<CreateTagModal <CreateTagModal

View file

@ -0,0 +1,128 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import NotificationsManager from "@/components/molecules/notifications_manager";
import UIThemeSettings from "./UIThemeSettings";
const setLogoUrl = vi.fn();
const setFaviconUrl = vi.fn();
vi.mock("@/contexts/ThemeContext", () => ({
useTheme: () => ({ logoUrl: null, setLogoUrl, faviconUrl: null, setFaviconUrl }),
}));
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: () => "",
getGlobalLitellmHeaderName: () => "Authorization",
}));
vi.mock("@/components/molecules/notifications_manager", () => ({
__esModule: true,
default: { success: vi.fn(), fromBackend: vi.fn() },
}));
const LOGO_PLACEHOLDER = "https://example.com/logo.png";
const FAVICON_PLACEHOLDER = "https://example.com/favicon.ico";
const okResponse = (values: Record<string, string | null> = {}) =>
Promise.resolve({ ok: true, json: () => Promise.resolve({ values }) } as Response);
const fetchMock = vi.fn<typeof fetch>();
const patchCalls = () => fetchMock.mock.calls.filter(([, init]) => init?.method === "PATCH");
const bodyOf = (call: Parameters<typeof fetch>) => JSON.parse(String(call[1]?.body));
describe("UIThemeSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
fetchMock.mockImplementation(() => okResponse());
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("should render nothing without an access token", () => {
const { container } = render(<UIThemeSettings userID="user-1" userRole="Admin" accessToken={null} />);
expect(container).toBeEmptyDOMElement();
expect(fetchMock).not.toHaveBeenCalled();
});
it("should load the saved logo and favicon urls into the inputs", async () => {
fetchMock.mockImplementation(() =>
okResponse({ logo_url: "https://cdn.example.com/logo.svg", favicon_url: "https://cdn.example.com/fav.ico" }),
);
render(<UIThemeSettings userID="user-1" userRole="Admin" accessToken="sk-test" />);
await waitFor(() => {
expect(screen.getByPlaceholderText(LOGO_PLACEHOLDER)).toHaveValue("https://cdn.example.com/logo.svg");
});
expect(screen.getByPlaceholderText(FAVICON_PLACEHOLDER)).toHaveValue("https://cdn.example.com/fav.ico");
expect(setLogoUrl).toHaveBeenCalledWith("https://cdn.example.com/logo.svg");
expect(setFaviconUrl).toHaveBeenCalledWith("https://cdn.example.com/fav.ico");
});
it("should save the entered urls and report success", async () => {
const user = userEvent.setup();
render(<UIThemeSettings userID="user-1" userRole="Admin" accessToken="sk-test" />);
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
await user.type(screen.getByPlaceholderText(LOGO_PLACEHOLDER), "https://a.test/logo.png");
await user.type(screen.getByPlaceholderText(FAVICON_PLACEHOLDER), "https://a.test/fav.ico");
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => expect(patchCalls()).toHaveLength(1));
expect(bodyOf(patchCalls()[0])).toEqual({
logo_url: "https://a.test/logo.png",
favicon_url: "https://a.test/fav.ico",
});
await waitFor(() =>
expect(NotificationsManager.success).toHaveBeenCalledWith("Theme settings updated successfully!"),
);
});
it("should surface a backend failure when saving fails", async () => {
const user = userEvent.setup();
render(<UIThemeSettings userID="user-1" userRole="Admin" accessToken="sk-test" />);
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
fetchMock.mockImplementation(() => Promise.resolve({ ok: false } as Response));
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() =>
expect(NotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to update theme settings"),
);
expect(NotificationsManager.success).not.toHaveBeenCalled();
});
it("should clear both inputs and persist nulls when resetting to default", async () => {
const user = userEvent.setup();
fetchMock.mockImplementation(() =>
okResponse({ logo_url: "https://cdn.example.com/logo.svg", favicon_url: "https://cdn.example.com/fav.ico" }),
);
render(<UIThemeSettings userID="user-1" userRole="Admin" accessToken="sk-test" />);
await waitFor(() => {
expect(screen.getByPlaceholderText(LOGO_PLACEHOLDER)).toHaveValue("https://cdn.example.com/logo.svg");
});
await user.click(screen.getByRole("button", { name: "Reset to Default" }));
await waitFor(() => expect(patchCalls()).toHaveLength(1));
expect(bodyOf(patchCalls()[0])).toEqual({ logo_url: null, favicon_url: null });
expect(screen.getByPlaceholderText(LOGO_PLACEHOLDER)).toHaveValue("");
expect(screen.getByPlaceholderText(FAVICON_PLACEHOLDER)).toHaveValue("");
expect(setLogoUrl).toHaveBeenLastCalledWith(null);
expect(setFaviconUrl).toHaveBeenLastCalledWith(null);
await waitFor(() => expect(NotificationsManager.success).toHaveBeenCalledWith("Theme settings reset to default!"));
});
});

View file

@ -1,5 +1,9 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Card, Title, Text, TextInput, Button } from "@tremor/react"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking"; import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import NotificationsManager from "@/components/molecules/notifications_manager"; import NotificationsManager from "@/components/molecules/notifications_manager";
@ -113,50 +117,58 @@ const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, acc
return ( return (
<div className="w-full mx-auto max-w-4xl px-6 py-8"> <div className="w-full mx-auto max-w-4xl px-6 py-8">
<div className="mb-8"> <div className="mb-8">
<Title className="text-2xl font-bold mb-2">UI Theme Customization</Title> <h1 className="mb-2 text-2xl font-bold">UI Theme Customization</h1>
<Text className="text-gray-600">Customize your LiteLLM admin dashboard with a custom logo and favicon.</Text> <p className="text-sm text-muted-foreground">
Customize your LiteLLM admin dashboard with a custom logo and favicon.
</p>
</div> </div>
<Card className="shadow-xs p-6"> <Card>
<div className="space-y-6"> <CardContent className="space-y-6">
<div> <div>
<Text className="text-sm font-medium text-gray-700 mb-2 block">Custom Logo URL</Text> <Label htmlFor="ui-theme-logo-url" className="mb-2">
<TextInput Custom Logo URL
</Label>
<Input
id="ui-theme-logo-url"
placeholder="https://example.com/logo.png" placeholder="https://example.com/logo.png"
value={logoUrlInput} value={logoUrlInput}
onValueChange={(v) => { onChange={(event) => {
setLogoUrlInput(v); setLogoUrlInput(event.target.value);
setLogoUrl(v || null); setLogoUrl(event.target.value || null);
}} }}
className="w-full"
/> />
<Text className="text-xs text-gray-500 mt-1"> <p className="mt-1 text-xs text-muted-foreground">
Enter a URL for your custom logo or leave empty for default Enter a URL for your custom logo or leave empty for default
</Text> </p>
</div> </div>
<div> <div>
<Text className="text-sm font-medium text-gray-700 mb-2 block">Custom Favicon URL</Text> <Label htmlFor="ui-theme-favicon-url" className="mb-2">
<TextInput Custom Favicon URL
</Label>
<Input
id="ui-theme-favicon-url"
placeholder="https://example.com/favicon.ico" placeholder="https://example.com/favicon.ico"
value={faviconUrlInput} value={faviconUrlInput}
onValueChange={(v) => { onChange={(event) => {
setFaviconUrlInput(v); setFaviconUrlInput(event.target.value);
setFaviconUrl(v || null); setFaviconUrl(event.target.value || null);
}} }}
className="w-full"
/> />
<Text className="text-xs text-gray-500 mt-1"> <p className="mt-1 text-xs text-muted-foreground">
Enter a URL for your custom favicon (.ico, .png, or .svg) or leave empty for default Enter a URL for your custom favicon (.ico, .png, or .svg) or leave empty for default
</Text> </p>
</div> </div>
<div className="flex gap-3 pt-4"> <div className="flex gap-3 pt-4">
<Button onClick={handleSave} loading={loading} disabled={loading} color="indigo"> <Button onClick={handleSave} disabled={loading}>
{loading && <UiLoadingSpinner className="size-4" />}
Save Changes Save Changes
</Button> </Button>
<Button onClick={handleReset} loading={loading} disabled={loading} variant="secondary" color="gray"> <Button variant="outline" onClick={handleReset} disabled={loading}>
{loading && <UiLoadingSpinner className="size-4" />}
Reset to Default Reset to Default
</Button> </Button>
</div> </div>
</div> </CardContent>
</Card> </Card>
</div> </div>
); );

View file

@ -1,4 +1,5 @@
import { render, screen, fireEvent } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import TestVectorStoreTab from "./TestVectorStoreTab"; import TestVectorStoreTab from "./TestVectorStoreTab";
import { VectorStore } from "@/components/vector_store_management/types"; import { VectorStore } from "@/components/vector_store_management/types";
@ -60,31 +61,24 @@ describe("TestVectorStoreTab", () => {
expect(screen.getByTestId("tester-access-token")).toHaveTextContent("test-token"); expect(screen.getByTestId("tester-access-token")).toHaveTextContent("test-token");
}); });
it("should update VectorStoreTester when selecting different vector store", () => { it("should update VectorStoreTester when selecting different vector store", async () => {
const user = userEvent.setup();
render(<TestVectorStoreTab accessToken="test-token" vectorStores={mockVectorStores} />); render(<TestVectorStoreTab accessToken="test-token" vectorStores={mockVectorStores} />);
// Find the select component await user.click(screen.getByRole("combobox"));
const selectElement = screen.getByRole("combobox"); await user.click(await screen.findByText("Test Store 2"));
// Change selection
fireEvent.mouseDown(selectElement);
// Wait for options to appear and click the second one
const option2 = screen.getByText("Test Store 2");
fireEvent.click(option2);
// Verify the tester component updated
expect(screen.getByTestId("tester-vector-store-id")).toHaveTextContent("vs_456"); expect(screen.getByTestId("tester-vector-store-id")).toHaveTextContent("vs_456");
}); });
it("should display vector store names in select options", () => { it("should display vector store names in select options", async () => {
const user = userEvent.setup();
render(<TestVectorStoreTab accessToken="test-token" vectorStores={mockVectorStores} />); render(<TestVectorStoreTab accessToken="test-token" vectorStores={mockVectorStores} />);
const selectElement = screen.getByRole("combobox"); await user.click(screen.getByRole("combobox"));
fireEvent.mouseDown(selectElement);
// Use getAllByText since the selected value also shows the name // The selected store's name may also render in the trigger, so only require at least one match.
expect(screen.getAllByText("Test Store 1").length).toBeGreaterThan(0); expect((await screen.findAllByText("Test Store 1")).length).toBeGreaterThan(0);
expect(screen.getByText("Test Store 2")).toBeInTheDocument(); expect(screen.getByText("Test Store 2")).toBeInTheDocument();
}); });
}); });

View file

@ -1,24 +1,32 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Card, Select, Typography } from "antd"; import { Card, CardContent } from "@/components/ui/card";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { VectorStoreTester } from "./VectorStoreTester"; import { VectorStoreTester } from "./VectorStoreTester";
import { VectorStore } from "@/components/vector_store_management/types"; import { VectorStore } from "@/components/vector_store_management/types";
const { Text, Title } = Typography;
interface TestVectorStoreTabProps { interface TestVectorStoreTabProps {
accessToken: string | null; accessToken: string | null;
vectorStores: VectorStore[]; vectorStores: VectorStore[];
} }
const storeLabel = (store: VectorStore) => store.vector_store_name || store.vector_store_id;
const TestVectorStoreTab: React.FC<TestVectorStoreTabProps> = ({ accessToken, vectorStores }) => { const TestVectorStoreTab: React.FC<TestVectorStoreTabProps> = ({ accessToken, vectorStores }) => {
const [selectedVectorStoreId, setSelectedVectorStoreId] = useState<string | undefined>( const [selectedVectorStore, setSelectedVectorStore] = useState<VectorStore | null>(vectorStores[0] ?? null);
vectorStores.length > 0 ? vectorStores[0].vector_store_id : undefined,
);
if (!accessToken) { if (!accessToken) {
return ( return (
<Card> <Card>
<Text type="secondary">Access token is required to test vector stores.</Text> <CardContent>
<p className="text-sm text-muted-foreground">Access token is required to test vector stores.</p>
</CardContent>
</Card> </Card>
); );
} }
@ -26,9 +34,11 @@ const TestVectorStoreTab: React.FC<TestVectorStoreTabProps> = ({ accessToken, ve
if (vectorStores.length === 0) { if (vectorStores.length === 0) {
return ( return (
<Card> <Card>
<div className="text-center py-8"> <CardContent>
<Text type="secondary">No vector stores available. Create one first to test it.</Text> <div className="py-8 text-center">
<p className="text-sm text-muted-foreground">No vector stores available. Create one first to test it.</p>
</div> </div>
</CardContent>
</Card> </Card>
); );
} }
@ -36,36 +46,41 @@ const TestVectorStoreTab: React.FC<TestVectorStoreTabProps> = ({ accessToken, ve
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card> <Card>
<div className="space-y-4"> <CardContent className="space-y-4">
<div> <div>
<Title level={5}>Select Vector Store</Title> <h5 className="text-base font-medium text-foreground">Select Vector Store</h5>
<Text type="secondary">Choose a vector store to test search queries against</Text> <p className="text-sm text-muted-foreground">Choose a vector store to test search queries against</p>
</div> </div>
<Select <Combobox
value={selectedVectorStoreId} items={vectorStores}
onChange={setSelectedVectorStoreId} value={selectedVectorStore}
placeholder="Select a vector store" onValueChange={setSelectedVectorStore}
size="large" itemToStringLabel={storeLabel}
style={{ width: "100%" }}
showSearch
optionFilterProp="children"
> >
{vectorStores.map((vs) => ( <ComboboxInput className="w-full" placeholder="Select a vector store" />
<Select.Option key={vs.vector_store_id} value={vs.vector_store_id}> <ComboboxContent>
<ComboboxEmpty>No matching vector stores</ComboboxEmpty>
<ComboboxList>
{(store: VectorStore) => (
<ComboboxItem key={store.vector_store_id} value={store}>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium">{vs.vector_store_name || vs.vector_store_id}</span> <span className="font-medium">{storeLabel(store)}</span>
{vs.vector_store_name && ( {store.vector_store_name && (
<span className="text-xs text-gray-500 font-mono">{vs.vector_store_id}</span> <span className="font-mono text-xs text-muted-foreground">{store.vector_store_id}</span>
)} )}
</div> </div>
</Select.Option> </ComboboxItem>
))} )}
</Select> </ComboboxList>
</div> </ComboboxContent>
</Combobox>
</CardContent>
</Card> </Card>
{selectedVectorStoreId && <VectorStoreTester vectorStoreId={selectedVectorStoreId} accessToken={accessToken} />} {selectedVectorStore && (
<VectorStoreTester vectorStoreId={selectedVectorStore.vector_store_id} accessToken={accessToken} />
)}
</div> </div>
); );
}; };

View file

@ -0,0 +1,156 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { vectorStoreSearchCall } from "@/components/networking";
import { VectorStoreTester } from "./VectorStoreTester";
vi.mock("@/components/networking", () => ({
vectorStoreSearchCall: vi.fn(),
}));
const mockWarning = vi.fn();
vi.mock("@/components/molecules/message_manager", () => ({
__esModule: true,
default: { warning: (...args: unknown[]) => mockWarning(...args) },
}));
const mockFromBackend = vi.fn();
const mockSuccess = vi.fn();
vi.mock("@/components/molecules/notifications_manager", () => ({
__esModule: true,
default: {
fromBackend: (...args: unknown[]) => mockFromBackend(...args),
success: (...args: unknown[]) => mockSuccess(...args),
},
}));
const mockSearch = vi.mocked(vectorStoreSearchCall);
const searchResponse = {
object: "vector_store.search_results.page",
search_query: "hello",
data: [
{
score: 0.91234,
content: [{ text: "the quick brown fox", type: "text" }],
file_id: "file-1",
filename: "notes.txt",
attributes: { source: "manual" },
},
],
};
const EMPTY_STATE = "Test your vector store by entering a search query below";
const renderTester = () => render(<VectorStoreTester vectorStoreId="vs_123" accessToken="sk-test" />);
const queryInput = () => screen.getByPlaceholderText(/enter your search query/i);
const searchButton = () => screen.getByRole("button", { name: /search/i });
describe("VectorStoreTester", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSearch.mockResolvedValue(searchResponse);
});
it("shows the empty state before any search has run", () => {
renderTester();
expect(screen.getByText(EMPTY_STATE)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /clear history/i })).not.toBeInTheDocument();
});
it("does not search until a non-blank query is entered", async () => {
const user = userEvent.setup();
renderTester();
await user.click(searchButton());
expect(mockSearch).not.toHaveBeenCalled();
await user.type(queryInput(), "hello");
await user.click(searchButton());
await waitFor(() => expect(mockSearch).toHaveBeenCalledWith("sk-test", "vs_123", "hello"));
});
it("renders the returned result and clears the query input", async () => {
const user = userEvent.setup();
renderTester();
await user.type(queryInput(), "hello");
await user.click(searchButton());
expect(await screen.findByText("Result 1")).toBeInTheDocument();
expect(screen.getByText("1 results")).toBeInTheDocument();
expect(screen.getByText("Score: 0.9123")).toBeInTheDocument();
expect(screen.queryByText(EMPTY_STATE)).not.toBeInTheDocument();
await waitFor(() => expect(queryInput()).toHaveValue(""));
});
it("expands a result to reveal its content and metadata", async () => {
const user = userEvent.setup();
renderTester();
await user.type(queryInput(), "hello");
await user.click(searchButton());
expect(await screen.findByText("Result 1")).toBeInTheDocument();
expect(screen.queryByText("the quick brown fox")).not.toBeInTheDocument();
await user.click(screen.getByText("Result 1"));
expect(screen.getByText("the quick brown fox")).toBeInTheDocument();
expect(screen.getByText("File ID:").parentElement).toHaveTextContent("file-1");
expect(screen.getByText("Filename:").parentElement).toHaveTextContent("notes.txt");
});
it("warns instead of searching when the query is only whitespace", async () => {
const user = userEvent.setup();
renderTester();
await user.type(queryInput(), " ");
await user.type(queryInput(), "{Enter}");
expect(mockWarning).toHaveBeenCalledWith("Please enter a search query");
expect(mockSearch).not.toHaveBeenCalled();
});
it("submits on Enter but not on Shift+Enter", async () => {
const user = userEvent.setup();
renderTester();
await user.type(queryInput(), "hello");
await user.type(queryInput(), "{Shift>}{Enter}{/Shift}");
expect(mockSearch).not.toHaveBeenCalled();
await user.type(queryInput(), "{Enter}");
await waitFor(() => expect(mockSearch).toHaveBeenCalledTimes(1));
});
it("reports a failed search and keeps the history empty", async () => {
const user = userEvent.setup();
mockSearch.mockRejectedValue(new Error("boom"));
renderTester();
await user.type(queryInput(), "hello");
await user.click(searchButton());
await waitFor(() => expect(mockFromBackend).toHaveBeenCalledWith("Failed to search vector store"));
expect(screen.getByText(EMPTY_STATE)).toBeInTheDocument();
});
it("clears the search history", async () => {
const user = userEvent.setup();
renderTester();
await user.type(queryInput(), "hello");
await user.click(searchButton());
expect(await screen.findByText("Result 1")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /clear history/i }));
expect(screen.queryByText("Result 1")).not.toBeInTheDocument();
expect(screen.getByText(EMPTY_STATE)).toBeInTheDocument();
});
});

View file

@ -1,12 +1,13 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Button, Input, Card, Typography, Spin, Divider } from "antd";
import MessageManager from "@/components/molecules/message_manager"; import MessageManager from "@/components/molecules/message_manager";
import { SendOutlined, DatabaseOutlined, LoadingOutlined, DownOutlined, RightOutlined } from "@ant-design/icons"; import { ChevronDown, ChevronRight, Database, Send } from "lucide-react";
import { vectorStoreSearchCall } from "@/components/networking"; import { vectorStoreSearchCall } from "@/components/networking";
import NotificationsManager from "@/components/molecules/notifications_manager"; import NotificationsManager from "@/components/molecules/notifications_manager";
import { Button } from "@/components/ui/button";
const { TextArea } = Input; import { Card } from "@/components/ui/card";
const { Text, Title } = Typography; import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
interface VectorStoreContent { interface VectorStoreContent {
text: string; text: string;
@ -98,18 +99,16 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
}; };
return ( return (
<Card className="w-full rounded-xl shadow-md"> <Card className={`w-full py-0 shadow-md ${className}`}>
<div className="flex flex-col h-[600px]"> <div className="flex h-150 flex-col">
{/* Header */} {/* Header */}
<div className="p-4 border-b border-gray-200 flex justify-between items-center"> <div className="flex items-center justify-between border-b p-4">
<div className="flex items-center"> <div className="flex items-center">
<DatabaseOutlined className="mr-2 text-blue-500" /> <Database className="mr-2 size-4 text-primary" />
<Title level={4} className="mb-0"> <h4 className="text-base font-medium text-foreground">Test Vector Store</h4>
Test Vector Store
</Title>
</div> </div>
{searchHistory.length > 0 && ( {searchHistory.length > 0 && (
<Button onClick={clearHistory} size="small"> <Button variant="outline" size="sm" onClick={clearHistory}>
Clear History Clear History
</Button> </Button>
)} )}
@ -118,9 +117,9 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
{/* Results Area */} {/* Results Area */}
<div className="flex-1 overflow-auto p-4 pb-0"> <div className="flex-1 overflow-auto p-4 pb-0">
{searchHistory.length === 0 ? ( {searchHistory.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-gray-400"> <div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<DatabaseOutlined style={{ fontSize: "48px", marginBottom: "16px" }} /> <Database className="mb-4 size-12" />
<Text>Test your vector store by entering a search query below</Text> <p className="text-sm">Test your vector store by entering a search query below</p>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
@ -128,10 +127,10 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
<div key={index} className="space-y-2"> <div key={index} className="space-y-2">
{/* User Query */} {/* User Query */}
<div className="text-right"> <div className="text-right">
<div className="inline-block max-w-[80%] rounded-lg shadow-xs p-3 bg-blue-50 border border-blue-200"> <div className="inline-block max-w-[80%] rounded-lg bg-muted p-3 shadow-xs ring-1 ring-foreground/10">
<div className="flex items-center gap-2 mb-1"> <div className="mb-1 flex items-center gap-2">
<strong className="text-sm">Query</strong> <strong className="text-sm">Query</strong>
<span className="text-xs text-gray-500">{formatTimestamp(entry.timestamp)}</span> <span className="text-xs text-muted-foreground">{formatTimestamp(entry.timestamp)}</span>
</div> </div>
<div className="text-left">{entry.query}</div> <div className="text-left">{entry.query}</div>
</div> </div>
@ -139,12 +138,12 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
{/* Vector Store Response */} {/* Vector Store Response */}
<div className="text-left"> <div className="text-left">
<div className="inline-block max-w-[80%] rounded-lg shadow-xs p-3 bg-white border border-gray-200"> <div className="inline-block max-w-[80%] rounded-lg bg-card p-3 shadow-xs ring-1 ring-foreground/10">
<div className="flex items-center gap-2 mb-2"> <div className="mb-2 flex items-center gap-2">
<DatabaseOutlined className="text-green-500" /> <Database className="size-4 text-primary" />
<strong className="text-sm">Vector Store Results</strong> <strong className="text-sm">Vector Store Results</strong>
{entry.response && ( {entry.response && (
<span className="text-xs px-2 py-0.5 rounded-sm bg-gray-100 text-gray-600"> <span className="rounded-sm bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{entry.response.data?.length || 0} results {entry.response.data?.length || 0} results
</span> </span>
)} )}
@ -156,40 +155,42 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
const isExpanded = expandedResults[`${index}-${resultIndex}`] || false; const isExpanded = expandedResults[`${index}-${resultIndex}`] || false;
return ( return (
<div key={resultIndex} className="border rounded-lg overflow-hidden bg-gray-50"> <div key={resultIndex} className="overflow-hidden rounded-lg border bg-muted/50">
{/* Clickable Header */} {/* Clickable Header */}
<div <div
className="flex justify-between items-center p-3 cursor-pointer hover:bg-gray-100 transition-colors" className="flex cursor-pointer items-center justify-between p-3 transition-colors hover:bg-muted"
onClick={() => toggleResultExpansion(index, resultIndex)} onClick={() => toggleResultExpansion(index, resultIndex)}
> >
<div className="flex items-center"> <div className="flex items-center">
{isExpanded ? ( {isExpanded ? (
<DownOutlined className="text-gray-500 mr-2" /> <ChevronDown className="mr-2 size-4 text-muted-foreground" />
) : ( ) : (
<RightOutlined className="text-gray-500 mr-2" /> <ChevronRight className="mr-2 size-4 text-muted-foreground" />
)} )}
<span className="font-medium text-sm">Result {resultIndex + 1}</span> <span className="text-sm font-medium">Result {resultIndex + 1}</span>
{/* Show preview of content when collapsed */} {/* Show preview of content when collapsed */}
{!isExpanded && result.content && result.content[0] && ( {!isExpanded && result.content && result.content[0] && (
<span className="ml-2 text-xs text-gray-500 truncate max-w-md"> <span className="ml-2 max-w-md truncate text-xs text-muted-foreground">
- {result.content[0].text.substring(0, 100)}... - {result.content[0].text.substring(0, 100)}...
</span> </span>
)} )}
</div> </div>
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-sm"> <span className="rounded-sm bg-muted px-2 py-1 text-xs text-foreground">
Score: {result.score.toFixed(4)} Score: {result.score.toFixed(4)}
</span> </span>
</div> </div>
{/* Expandable Content */} {/* Expandable Content */}
{isExpanded && ( {isExpanded && (
<div className="border-t bg-white p-3"> <div className="border-t bg-card p-3">
{/* Content */} {/* Content */}
{result.content && {result.content &&
result.content.map((content, contentIndex) => ( result.content.map((content, contentIndex) => (
<div key={contentIndex} className="mb-3"> <div key={contentIndex} className="mb-3">
<div className="text-xs text-gray-500 mb-1">Content ({content.type})</div> <div className="mb-1 text-xs text-muted-foreground">
<div className="text-sm bg-gray-50 p-3 rounded-sm border text-gray-800 max-h-40 overflow-y-auto"> Content ({content.type})
</div>
<div className="max-h-40 overflow-y-auto rounded-sm border bg-muted/50 p-3 text-sm text-foreground">
{content.text} {content.text}
</div> </div>
</div> </div>
@ -197,23 +198,23 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
{/* Metadata */} {/* Metadata */}
{(result.file_id || result.filename || result.attributes) && ( {(result.file_id || result.filename || result.attributes) && (
<div className="mt-3 pt-3 border-t border-gray-200"> <div className="mt-3 border-t pt-3">
<div className="text-xs text-gray-500 mb-2 font-medium">Metadata</div> <div className="mb-2 text-xs font-medium text-muted-foreground">Metadata</div>
<div className="space-y-2 text-xs"> <div className="space-y-2 text-xs">
{result.file_id && ( {result.file_id && (
<div className="bg-gray-50 p-2 rounded-sm"> <div className="rounded-sm bg-muted/50 p-2">
<span className="font-medium">File ID:</span> {result.file_id} <span className="font-medium">File ID:</span> {result.file_id}
</div> </div>
)} )}
{result.filename && ( {result.filename && (
<div className="bg-gray-50 p-2 rounded-sm"> <div className="rounded-sm bg-muted/50 p-2">
<span className="font-medium">Filename:</span> {result.filename} <span className="font-medium">Filename:</span> {result.filename}
</div> </div>
)} )}
{result.attributes && Object.keys(result.attributes).length > 0 && ( {result.attributes && Object.keys(result.attributes).length > 0 && (
<div className="bg-gray-50 p-2 rounded-sm"> <div className="rounded-sm bg-muted/50 p-2">
<span className="font-medium block mb-1">Attributes:</span> <span className="mb-1 block font-medium">Attributes:</span>
<pre className="text-xs bg-white p-2 rounded-sm border overflow-x-auto"> <pre className="overflow-x-auto rounded-sm border bg-card p-2 text-xs">
{JSON.stringify(result.attributes, null, 2)} {JSON.stringify(result.attributes, null, 2)}
</pre> </pre>
</div> </div>
@ -228,45 +229,40 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
})} })}
</div> </div>
) : ( ) : (
<div className="text-gray-500 text-sm">No results found</div> <div className="text-sm text-muted-foreground">No results found</div>
)} )}
</div> </div>
</div> </div>
{index < searchHistory.length - 1 && <Divider />} {index < searchHistory.length - 1 && <Separator />}
</div> </div>
))} ))}
</div> </div>
)} )}
{isLoading && ( {isLoading && (
<div className="flex justify-center items-center my-4"> <div className="my-4 flex items-center justify-center">
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} /> <UiLoadingSpinner className="size-6 text-primary" />
</div> </div>
)} )}
</div> </div>
{/* Input Area */} {/* Input Area */}
<div className="p-4 border-t border-gray-200 bg-white"> <div className="border-t bg-card p-4">
<div className="flex items-end space-x-2"> <div className="flex items-end space-x-2">
<div className="flex-1"> <div className="flex-1">
<TextArea <Textarea
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Enter your search query... (Shift+Enter for new line)" placeholder="Enter your search query... (Shift+Enter for new line)"
disabled={isLoading} disabled={isLoading}
autoSize={{ minRows: 1, maxRows: 4 }} rows={1}
style={{ resize: "none" }} className="field-sizing-fixed max-h-24 min-h-9 resize-none"
/> />
</div> </div>
<Button <Button onClick={handleSearch} disabled={isLoading || !query.trim()}>
type="primary" {isLoading ? <UiLoadingSpinner className="size-4" /> : <Send className="size-4" />}
onClick={handleSearch}
disabled={isLoading || !query.trim()}
icon={<SendOutlined />}
loading={isLoading}
>
Search Search
</Button> </Button>
</div> </div>

View file

@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { vectorStoreListCall } from "@/components/networking"; import { vectorStoreListCall } from "@/components/networking";
@ -25,18 +26,25 @@ vi.mock("./TestVectorStoreTab", () => ({ __esModule: true, default: () => null }
const mockVectorStoreListCall = vi.mocked(vectorStoreListCall); const mockVectorStoreListCall = vi.mocked(vectorStoreListCall);
const openManageTab = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole("tab", { name: "Manage Vector Stores" }));
};
describe("VectorStoreManagement loading state", () => { describe("VectorStoreManagement loading state", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("should resolve the loading state when accessToken is null instead of showing the skeleton forever", async () => { it("should resolve the loading state when accessToken is null instead of showing the skeleton forever", async () => {
const user = userEvent.setup();
render(<VectorStoreManagement accessToken={null} userID={null} userRole={null} />); render(<VectorStoreManagement accessToken={null} userID={null} userRole={null} />);
await openManageTab(user);
expect(await screen.findByText("table-loaded")).toBeInTheDocument(); expect(await screen.findByText("table-loaded")).toBeInTheDocument();
expect(mockVectorStoreListCall).not.toHaveBeenCalled(); expect(mockVectorStoreListCall).not.toHaveBeenCalled();
}); });
it("should show the loading state until the vector store fetch settles", async () => { it("should show the loading state until the vector store fetch settles", async () => {
const user = userEvent.setup();
let resolveFetch: (value: { data: never[] }) => void = () => {}; let resolveFetch: (value: { data: never[] }) => void = () => {};
mockVectorStoreListCall.mockReturnValue( mockVectorStoreListCall.mockReturnValue(
new Promise((resolve) => { new Promise((resolve) => {
@ -44,6 +52,7 @@ describe("VectorStoreManagement loading state", () => {
}), }),
); );
render(<VectorStoreManagement accessToken="sk-test" userID="user-1" userRole="Admin" />); render(<VectorStoreManagement accessToken="sk-test" userID="user-1" userRole="Admin" />);
await openManageTab(user);
expect(screen.getByText("table-loading")).toBeInTheDocument(); expect(screen.getByText("table-loading")).toBeInTheDocument();
resolveFetch({ data: [] }); resolveFetch({ data: [] });

View file

@ -1,17 +1,5 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { import { RefreshCw } from "lucide-react";
Icon,
Button as TremorButton,
Col,
Text,
Grid,
TabGroup,
TabList,
Tab,
TabPanels,
TabPanel,
} from "@tremor/react";
import { RefreshIcon } from "@heroicons/react/outline";
import { import {
vectorStoreListCall, vectorStoreListCall,
vectorStoreDeleteCall, vectorStoreDeleteCall,
@ -27,6 +15,9 @@ import CreateVectorStore from "./CreateVectorStore";
import TestVectorStoreTab from "./TestVectorStoreTab"; import TestVectorStoreTab from "./TestVectorStoreTab";
import { isAdminRole } from "@/utils/roles"; import { isAdminRole } from "@/utils/roles";
import NotificationsManager from "@/components/molecules/notifications_manager"; import NotificationsManager from "@/components/molecules/notifications_manager";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useVisitedTabs } from "@/hooks/useVisitedTabs";
interface VectorStoreProps { interface VectorStoreProps {
accessToken: string | null; accessToken: string | null;
@ -45,6 +36,7 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
const [selectedVectorStoreId, setSelectedVectorStoreId] = useState<string | null>(null); const [selectedVectorStoreId, setSelectedVectorStoreId] = useState<string | null>(null);
const [editVectorStore, setEditVectorStore] = useState(false); const [editVectorStore, setEditVectorStore] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const { onTabChange, hasVisited } = useVisitedTabs("create");
const fetchVectorStores = async () => { const fetchVectorStores = async () => {
if (!accessToken) { if (!accessToken) {
@ -147,44 +139,42 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
<div className="mx-4 h-[75vh]"> <div className="mx-4 h-[75vh]">
<div className="gap-2 p-8 h-[75vh] w-full mt-2"> <div className="gap-2 p-8 h-[75vh] w-full mt-2">
<div className="flex justify-between mt-2 w-full items-center mb-4"> <div className="flex justify-between mt-2 w-full items-center mb-4">
<h1>Vector Store Management</h1> <h1 className="text-xl font-semibold tracking-tight text-foreground">Vector Store Management</h1>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{lastRefreshed && <Text>Last Refreshed: {lastRefreshed}</Text>} {lastRefreshed && <p className="text-sm text-muted-foreground">Last Refreshed: {lastRefreshed}</p>}
<Icon <Button variant="outline" size="icon-sm" aria-label="Refresh" onClick={handleRefreshClick}>
icon={RefreshIcon} <RefreshCw className="size-4" />
variant="shadow" </Button>
size="xs"
className="self-center cursor-pointer"
onClick={handleRefreshClick}
/>
</div> </div>
</div> </div>
<Text className="mb-4"> <p className="mb-4 text-sm text-muted-foreground">
<p>You can use vector stores to store and retrieve LLM embeddings.</p> You can use vector stores to store and retrieve LLM embeddings.
</Text> </p>
<TabGroup> <Tabs defaultValue="create" onValueChange={onTabChange}>
<TabList className="mb-6"> <TabsList variant="line" className="mb-6 h-auto w-full justify-start rounded-none border-b p-0">
<Tab>Create Vector Store</Tab> <TabsTrigger value="create" className="flex-none rounded-none px-4 py-2">
<Tab>Manage Vector Stores</Tab> Create Vector Store
<Tab>Test Vector Store</Tab> </TabsTrigger>
</TabList> <TabsTrigger value="manage" className="flex-none rounded-none px-4 py-2">
Manage Vector Stores
</TabsTrigger>
<TabsTrigger value="test" className="flex-none rounded-none px-4 py-2">
Test Vector Store
</TabsTrigger>
</TabsList>
<TabPanels> <TabsContent keepMounted={hasVisited("create")} value="create">
{/* Tab 1: Create Vector Store */}
<TabPanel>
<CreateVectorStore accessToken={accessToken} onSuccess={handleVectorStoreCreated} /> <CreateVectorStore accessToken={accessToken} onSuccess={handleVectorStoreCreated} />
</TabPanel> </TabsContent>
{/* Tab 2: Manage Vector Stores */} <TabsContent keepMounted={hasVisited("manage")} value="manage">
<TabPanel> <Button className="mb-4" onClick={() => setIsCreateModalVisible(true)}>
<TremorButton className="mb-4" onClick={() => setIsCreateModalVisible(true)}>
+ Add Vector Store + Add Vector Store
</TremorButton> </Button>
<Grid numItems={1} className="gap-2 pt-2 pb-2 w-full mt-2"> <div className="grid grid-cols-1 gap-2 pt-2 pb-2 w-full mt-2">
<Col numColSpan={1}>
<VectorStoreTable <VectorStoreTable
data={vectorStores} data={vectorStores}
isLoading={isLoadingVectorStores} isLoading={isLoadingVectorStores}
@ -192,16 +182,13 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
/> />
</Col> </div>
</Grid> </TabsContent>
</TabPanel>
{/* Tab 3: Test Vector Store */} <TabsContent keepMounted={hasVisited("test")} value="test">
<TabPanel>
<TestVectorStoreTab accessToken={accessToken} vectorStores={vectorStores} /> <TestVectorStoreTab accessToken={accessToken} vectorStores={vectorStores} />
</TabPanel> </TabsContent>
</TabPanels> </Tabs>
</TabGroup>
{/* Create Vector Store Modal */} {/* Create Vector Store Modal */}
<VectorStoreForm <VectorStoreForm

View file

@ -0,0 +1,65 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import ConnectLayout from "./layout";
const { mockUseAuthorized, state } = vi.hoisted(() => {
const state = {
accessToken: "token-123" as string | null,
isAuthorized: true,
isLoading: false,
};
return {
state,
mockUseAuthorized: vi.fn(() => ({
accessToken: state.accessToken,
isAuthorized: state.isAuthorized,
isLoading: state.isLoading,
})),
};
});
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: mockUseAuthorized }));
vi.mock("@/components/navbar", () => ({ default: () => <div data-testid="navbar" /> }));
vi.mock("@/contexts/ThemeContext", () => ({
ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
describe("ConnectLayout", () => {
afterEach(() => {
state.accessToken = "token-123";
state.isAuthorized = true;
state.isLoading = false;
});
it("renders the connect surface for an authorized user without any chat-ui flag", () => {
render(
<ConnectLayout>
<div data-testid="page-content" />
</ConnectLayout>,
);
expect(screen.getByTestId("navbar")).toBeInTheDocument();
expect(screen.getByTestId("page-content")).toBeInTheDocument();
});
it("renders nothing when the user is not authorized", () => {
state.isAuthorized = false;
render(
<ConnectLayout>
<div data-testid="page-content" />
</ConnectLayout>,
);
expect(screen.queryByTestId("page-content")).not.toBeInTheDocument();
expect(screen.queryByTestId("navbar")).not.toBeInTheDocument();
});
it("renders nothing while authorization is still loading", () => {
state.isLoading = true;
render(
<ConnectLayout>
<div data-testid="page-content" />
</ConnectLayout>,
);
expect(screen.queryByTestId("page-content")).not.toBeInTheDocument();
expect(screen.queryByTestId("navbar")).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,20 @@
"use client";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import Navbar from "@/components/navbar";
import { ThemeProvider } from "@/contexts/ThemeContext";
export default function ConnectLayout({ children }: { children: React.ReactNode }) {
const { accessToken, isAuthorized, isLoading } = useAuthorized();
if (isLoading || !isAuthorized) return null;
return (
<ThemeProvider accessToken={accessToken}>
<div className="flex h-screen flex-col">
<Navbar accessToken={accessToken} isPublicPage={false} />
<div className="min-h-0 flex-1 overflow-auto">{children}</div>
</div>
</ThemeProvider>
);
}

View file

@ -0,0 +1,55 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import ConnectPage from "./page";
interface PanelProps {
accessToken: string;
selectedServers: string[];
onChange: (servers: string[]) => void;
}
const { mockReplace, mockPanel, state } = vi.hoisted(() => {
const state = {
oauthReturn: null as string | null,
};
return {
state,
mockReplace: vi.fn(),
mockPanel: vi.fn((_props: PanelProps) => <div data-testid="mcp-apps-panel" />),
};
});
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: mockReplace }),
useSearchParams: () => ({ get: (key: string) => (key === "mcpOauthReturn" ? state.oauthReturn : null) }),
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({ accessToken: "token-123" }),
}));
vi.mock("@/components/chat/MCPAppsPanel", () => ({ default: mockPanel }));
describe("ConnectPage", () => {
afterEach(() => {
state.oauthReturn = null;
mockReplace.mockClear();
mockPanel.mockClear();
});
it("renders the MCP connect panel with the user's access token", () => {
render(<ConnectPage />);
expect(screen.getByTestId("mcp-apps-panel")).toBeInTheDocument();
expect(mockPanel.mock.calls[0][0]).toMatchObject({ accessToken: "token-123", selectedServers: [] });
});
it("strips the mcpOauthReturn param from the URL after an OAuth return", () => {
state.oauthReturn = "apps";
window.history.replaceState({}, "", "/connect?mcpOauthReturn=apps");
render(<ConnectPage />);
expect(mockReplace).toHaveBeenCalledWith("/connect");
});
it("does not rewrite the URL when there is no OAuth return param", () => {
render(<ConnectPage />);
expect(mockReplace).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,36 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import MCPAppsPanel from "@/components/chat/MCPAppsPanel";
function ConnectPageContent() {
const { accessToken } = useAuthorized();
const [selectedServers, setSelectedServers] = useState<string[]>([]);
const router = useRouter();
const searchParams = useSearchParams();
const oauthReturn = searchParams.get("mcpOauthReturn");
useEffect(() => {
if (oauthReturn) {
const url = new URL(window.location.href);
url.searchParams.delete("mcpOauthReturn");
router.replace(url.pathname + url.search);
}
}, [oauthReturn, router]);
return (
<div className="mx-auto w-full max-w-5xl px-8 py-8">
<MCPAppsPanel accessToken={accessToken ?? ""} selectedServers={selectedServers} onChange={setSelectedServers} />
</div>
);
}
export default function ConnectPage() {
return (
<Suspense>
<ConnectPageContent />
</Suspense>
);
}

View file

@ -1,6 +1,6 @@
import { useCloudZeroSettings } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings"; import { useCloudZeroSettings } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { Card, Typography } from "antd"; import { Card, CardContent } from "@/components/ui/card";
import CloudZeroEmptyPlaceholder from "./CloudZeroEmptyPlaceholder"; import CloudZeroEmptyPlaceholder from "./CloudZeroEmptyPlaceholder";
import { useState } from "react"; import { useState } from "react";
import CloudZeroCreationModal from "./CloudZeroCreateModal"; import CloudZeroCreationModal from "./CloudZeroCreateModal";
@ -28,7 +28,9 @@ export default function CloudZeroCostTracking() {
if (isLoading) { if (isLoading) {
return ( return (
<Card> <Card>
<Typography.Text>Loading CloudZero settings...</Typography.Text> <CardContent>
<p className="text-sm text-muted-foreground">Loading CloudZero settings...</p>
</CardContent>
</Card> </Card>
); );
} }
@ -36,9 +38,11 @@ export default function CloudZeroCostTracking() {
if (error) { if (error) {
return ( return (
<Card> <Card>
<Typography.Text className="text-red-600"> <CardContent>
<p className="text-sm text-destructive">
Error loading CloudZero settings: {error instanceof Error ? error.message : String(error)} Error loading CloudZero settings: {error instanceof Error ? error.message : String(error)}
</Typography.Text> </p>
</CardContent>
</Card> </Card>
); );
} }

View file

@ -1,6 +1,5 @@
import { Empty, Typography, Button } from "antd"; import { Button } from "@/components/ui/button";
import { Inbox } from "lucide-react";
const { Title, Paragraph } = Typography;
interface CloudZeroEmptyPlaceholderProps { interface CloudZeroEmptyPlaceholderProps {
startCreation: () => void; startCreation: () => void;
@ -8,22 +7,17 @@ interface CloudZeroEmptyPlaceholderProps {
export default function CloudZeroEmptyPlaceholder({ startCreation }: CloudZeroEmptyPlaceholderProps) { export default function CloudZeroEmptyPlaceholder({ startCreation }: CloudZeroEmptyPlaceholderProps) {
return ( return (
<div className="bg-white p-12 rounded-lg border border-dashed border-gray-300 text-center max-w-2xl mx-auto mt-8"> <div className="mx-auto mt-8 max-w-2xl rounded-lg border border-dashed border-border bg-card p-12 text-center">
<Empty <div className="flex flex-col items-center gap-2">
image={Empty.PRESENTED_IMAGE_SIMPLE} <Inbox className="size-10 text-muted-foreground" aria-hidden />
description={ <h4 className="text-base font-semibold">No CloudZero Integration Found</h4>
<div className="space-y-2"> <p className="mx-auto max-w-md text-sm text-muted-foreground">
<Title level={4}>No CloudZero Integration Found</Title>
<Paragraph type="secondary" className="max-w-md mx-auto">
Connect your CloudZero account to start tracking and analyzing your cloud costs directly from LiteLLM. Connect your CloudZero account to start tracking and analyzing your cloud costs directly from LiteLLM.
</Paragraph> </p>
</div> <Button size="lg" onClick={startCreation} className="mt-4">
}
>
<Button type="primary" size="large" onClick={startCreation} className="flex items-center gap-2 mx-auto mt-4">
Add CloudZero Integration Add CloudZero Integration
</Button> </Button>
</Empty> </div>
</div> </div>
); );
} }

View file

@ -3,9 +3,22 @@ import { useCloudZeroExport } from "@/app/(dashboard)/hooks/cloudzero/useCloudZe
import { useCloudZeroDeleteSettings } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings"; import { useCloudZeroDeleteSettings } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import { Alert, Button, Card, Descriptions, Divider, Popconfirm, Tag } from "antd"; import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Separator } from "@/components/ui/separator";
import MessageManager from "@/components/molecules/message_manager"; import MessageManager from "@/components/molecules/message_manager";
import { CheckCircle, Edit, Play, Trash2, Upload } from "lucide-react"; import { CheckCircle, Pencil, Play, Trash2, Upload } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import CloudZeroUpdateModal from "./CloudZeroUpdateModal"; import CloudZeroUpdateModal from "./CloudZeroUpdateModal";
import { CloudZeroSettings } from "./types"; import { CloudZeroSettings } from "./types";
@ -15,10 +28,25 @@ interface CloudZeroIntegrationSettingsProps {
onSettingsUpdated: () => void; onSettingsUpdated: () => void;
} }
interface DetailRowProps {
label: string;
children: React.ReactNode;
}
const DetailRow = ({ label, children }: DetailRowProps) => (
<div className="grid grid-cols-1 border-b border-border last:border-b-0 sm:grid-cols-[220px_minmax(0,1fr)]">
<dt className="bg-muted/50 px-4 py-3 text-sm font-medium">{label}</dt>
<dd className="px-4 py-3 text-sm">{children}</dd>
</div>
);
const NotConfigured = () => <span className="text-muted-foreground italic">Not configured</span>;
export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: CloudZeroIntegrationSettingsProps) { export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: CloudZeroIntegrationSettingsProps) {
const { accessToken } = useAuthorized(); const { accessToken } = useAuthorized();
const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isExportConfirmOpen, setIsExportConfirmOpen] = useState(false);
const dryRunMutation = useCloudZeroDryRun(accessToken || ""); const dryRunMutation = useCloudZeroDryRun(accessToken || "");
const exportMutation = useCloudZeroExport(accessToken || ""); const exportMutation = useCloudZeroExport(accessToken || "");
@ -50,6 +78,7 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl
{ {
onSuccess: () => { onSuccess: () => {
MessageManager.success("Data successfully exported to CloudZero"); MessageManager.success("Data successfully exported to CloudZero");
setIsExportConfirmOpen(false);
}, },
onError: (error) => { onError: (error) => {
MessageManager.error(error?.message || "Failed to export data"); MessageManager.error(error?.message || "Failed to export data");
@ -96,112 +125,90 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl
return ( return (
<> <>
<div className="space-y-6 w-full max-w-4xl mx-auto"> <div className="mx-auto w-full max-w-4xl space-y-6">
<Card <Card>
title={ <CardHeader>
<div className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2 text-lg">
<span className="text-lg font-semibold">CloudZero Configuration</span> CloudZero Configuration
<Tag color="success" className="ml-2 capitalize"> <Badge variant="secondary" className="capitalize">
{settings.status || "Active"} {settings.status || "Active"}
</Tag> </Badge>
</div> </CardTitle>
} <CardAction className="flex gap-2">
extra={ <Button variant="outline" onClick={handleEdit}>
<div className="flex gap-2"> <Pencil />
<Button icon={<Edit size={16} />} onClick={handleEdit} className="flex items-center gap-2">
Edit Edit
</Button> </Button>
<Button <Button variant="destructive" onClick={handleDeleteClick}>
danger <Trash2 />
icon={<Trash2 size={16} />}
onClick={handleDeleteClick}
className="flex items-center gap-2"
>
Delete Delete
</Button> </Button>
</CardAction>
</CardHeader>
<CardContent>
<dl className="rounded-md border border-border">
<DetailRow label="API Key (Redacted)">
<span className="font-mono">{settings.api_key_masked || <NotConfigured />}</span>
</DetailRow>
<DetailRow label="Connection ID">
<span className="font-mono">{settings.connection_id || <NotConfigured />}</span>
</DetailRow>
<DetailRow label="Timezone">
{settings.timezone || <span className="text-muted-foreground italic">Default (UTC)</span>}
</DetailRow>
</dl>
<div className="mt-6 flex items-center gap-3">
<span className="text-sm text-muted-foreground">Actions</span>
<Separator className="flex-1" />
</div> </div>
}
className="shadow-xs"
>
<Descriptions
bordered
column={{
xxl: 1,
xl: 1,
lg: 1,
md: 1,
sm: 1,
xs: 1,
}}
>
<Descriptions.Item label="API Key (Redacted)">
<span className="font-mono text-gray-600">
{settings.api_key_masked || <span className="text-gray-400 italic">Not configured</span>}
</span>
</Descriptions.Item>
<Descriptions.Item label="Connection ID">
<span className="font-mono text-gray-600">
{settings.connection_id || <span className="text-gray-400 italic">Not configured</span>}
</span>
</Descriptions.Item>
<Descriptions.Item label="Timezone">
{settings.timezone || <span className="text-gray-400 italic">Default (UTC)</span>}
</Descriptions.Item>
</Descriptions>
<Divider orientation="left" className="text-gray-500"> <div className="mt-4 mb-6 flex flex-wrap gap-4">
Actions <Button variant="outline" onClick={handleDryRun} disabled={dryRunMutation.isPending}>
</Divider> <Play />
<div className="flex flex-wrap gap-4 mb-6">
<Button
onClick={handleDryRun}
loading={dryRunMutation.isPending}
icon={<Play size={16} />}
className="flex items-center gap-2"
>
Run Dry Run Simulation Run Dry Run Simulation
</Button> </Button>
<Popconfirm <Button onClick={() => setIsExportConfirmOpen(true)} disabled={exportMutation.isPending}>
title="Export Data to CloudZero" <Upload />
description="This will push the current accumulated cost data to CloudZero. Continue?"
onConfirm={handleExport}
okText="Export"
cancelText="Cancel"
>
<Button
type="primary"
loading={exportMutation.isPending}
icon={<Upload size={16} />}
className="flex items-center gap-2"
>
Export Data Now Export Data Now
</Button> </Button>
</Popconfirm>
</div> </div>
{dryRunResult && ( {dryRunResult && (
<div className="mt-6 animate-in fade-in slide-in-from-top-4 duration-300"> <Alert>
<Alert <CheckCircle />
message="Dry Run Results" <AlertTitle>Dry Run Results</AlertTitle>
description={ <AlertDescription>
<div className="mt-2"> <p>Simulation output for connection: {settings.connection_id}</p>
<p className="mb-2 text-gray-600">Simulation output for connection: {settings.connection_id}</p> <pre className="overflow-x-auto rounded-md border border-border bg-muted p-4 font-mono text-xs text-foreground">
<pre className="bg-gray-50 p-4 rounded-md border border-gray-200 overflow-x-auto text-xs font-mono text-gray-800">
{dryRunResult} {dryRunResult}
</pre> </pre>
</div> </AlertDescription>
} </Alert>
type="info"
showIcon
icon={<CheckCircle className="text-blue-500" />}
/>
</div>
)} )}
</CardContent>
</Card> </Card>
</div> </div>
<AlertDialog open={isExportConfirmOpen} onOpenChange={setIsExportConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Export Data to CloudZero</AlertDialogTitle>
<AlertDialogDescription>
This will push the current accumulated cost data to CloudZero. Continue?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={exportMutation.isPending}>Cancel</AlertDialogCancel>
<Button onClick={handleExport} disabled={exportMutation.isPending}>
Export
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<CloudZeroUpdateModal <CloudZeroUpdateModal
open={isEditModalOpen} open={isEditModalOpen}
onOk={handleEditModalOk} onOk={handleEditModalOk}

View file

@ -1,5 +1,9 @@
import { CodeOutlined, PlayCircleOutlined } from "@ant-design/icons"; import { Code, CircleAlert, CirclePlay, Info } from "lucide-react";
import { Alert, Button, Card, Input, Space, Tabs, Typography } from "antd"; import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import ModelSelector from "@/components/common_components/ModelSelector"; import ModelSelector from "@/components/common_components/ModelSelector";
import { TestResult } from "./semanticFilterTestUtils"; import { TestResult } from "./semanticFilterTestUtils";
@ -30,21 +34,33 @@ export default function MCPSemanticFilterTestPanel({
testError, testError,
curlCommand, curlCommand,
}: MCPSemanticFilterTestPanelProps) { }: MCPSemanticFilterTestPanelProps) {
const canRunTest = testQuery && testModel && filterEnabled;
const testDisabled = isTesting || !canRunTest;
return ( return (
<Card title="Test Configuration" style={{ marginBottom: 16 }}> <Card className="mb-4">
<Tabs <CardHeader>
defaultActiveKey="test" <CardTitle>Test Configuration</CardTitle>
items={[ </CardHeader>
{ <CardContent>
key: "test", <Tabs defaultValue="test">
label: "Test", <TabsList>
children: ( <TabsTrigger value="test" className="flex-none">
<Space direction="vertical" style={{ width: "100%" }} size="large"> Test
</TabsTrigger>
<TabsTrigger value="api" className="flex-none">
API Usage
</TabsTrigger>
</TabsList>
<TabsContent value="test">
<div className="flex w-full flex-col gap-6">
<div> <div>
<Typography.Text strong style={{ display: "block", marginBottom: 8 }}> <p className="mb-2 flex items-center gap-1.5 font-medium">
<PlayCircleOutlined /> Test Query <CirclePlay className="size-4" /> Test Query
</Typography.Text> </p>
<Input.TextArea <Textarea
className="field-sizing-fixed"
placeholder="Enter a test query to see which tools would be selected..." placeholder="Enter a test query to see which tools would be selected..."
value={testQuery} value={testQuery}
onChange={(e) => setTestQuery(e.target.value)} onChange={(e) => setTestQuery(e.target.value)}
@ -64,114 +80,86 @@ export default function MCPSemanticFilterTestPanel({
/> />
</div> </div>
<Button <Button className="w-full" onClick={onTest} disabled={testDisabled}>
type="primary" <CirclePlay />
icon={<PlayCircleOutlined />}
onClick={onTest}
loading={isTesting}
disabled={!testQuery || !testModel || !filterEnabled}
block
>
Test Filter Test Filter
</Button> </Button>
{!filterEnabled && ( {!filterEnabled && (
<Alert <Alert>
type="warning" <Info />
message="Semantic filtering is disabled" <AlertTitle>Semantic filtering is disabled</AlertTitle>
description="Enable semantic filtering and save settings to test the filter." <AlertDescription>Enable semantic filtering and save settings to test the filter.</AlertDescription>
showIcon </Alert>
/>
)} )}
{testError && ( {testError && (
<Alert <Alert variant="destructive" className="mb-4">
type="error" <CircleAlert />
message="Semantic filtering did not run" <AlertTitle>Semantic filtering did not run</AlertTitle>
description={testError} <AlertDescription>{testError}</AlertDescription>
showIcon </Alert>
style={{ marginBottom: 16 }}
/>
)} )}
{testResult && ( {testResult && (
<div> <div>
<Typography.Title level={5}>Results</Typography.Title> <h5 className="mb-2 text-base font-medium">Results</h5>
<Alert <Alert className="mb-4">
type={testResult.totalTools - testResult.selectedTools > 0 ? "success" : "warning"} <Info />
message={`${testResult.selectedTools} of ${testResult.totalTools} tools selected`} <AlertTitle>
description={`${testResult.totalTools - testResult.selectedTools} tools filtered out`} {testResult.selectedTools} of {testResult.totalTools} tools selected
showIcon </AlertTitle>
style={{ marginBottom: 16 }} <AlertDescription>
/> {testResult.totalTools - testResult.selectedTools} tools filtered out
</AlertDescription>
</Alert>
<div> <div>
<Typography.Text strong style={{ display: "block", marginBottom: 8 }}> <p className="mb-2 block font-medium">Selected Tools:</p>
Selected Tools: <ul className="m-0 list-disc pl-5">
</Typography.Text>
<ul style={{ paddingLeft: 20, margin: 0 }}>
{testResult.tools.map((tool, index) => ( {testResult.tools.map((tool, index) => (
<li key={index} style={{ marginBottom: 4 }}> <li key={index} className="mb-1">
<Typography.Text>{tool}</Typography.Text> <span>{tool}</span>
</li> </li>
))} ))}
</ul> </ul>
{testResult.selectedTools > testResult.tools.length && ( {testResult.selectedTools > testResult.tools.length && (
<Typography.Text type="secondary" style={{ display: "block", marginTop: 8 }}> <p className="mt-2 block text-sm text-muted-foreground">
+{testResult.selectedTools - testResult.tools.length} more selected tools not shown +{testResult.selectedTools - testResult.tools.length} more selected tools not shown
</Typography.Text> </p>
)} )}
</div> </div>
</div> </div>
)} )}
</Space> </div>
), </TabsContent>
},
{ <TabsContent value="api">
key: "api",
label: "API Usage",
children: (
<div> <div>
<Space style={{ marginBottom: 8 }}> <div className="mb-2 flex items-center gap-2">
<CodeOutlined /> <Code className="size-4" />
<Typography.Text strong>API Usage</Typography.Text> <p className="font-medium">API Usage</p>
</Space> </div>
<Typography.Text type="secondary" style={{ display: "block", marginBottom: 8 }}> <p className="mb-2 block text-sm text-muted-foreground">
Use this curl command to test the semantic filter with your current configuration. Use this curl command to test the semantic filter with your current configuration.
</Typography.Text> </p>
<Typography.Text strong style={{ display: "block", marginBottom: 8 }}> <p className="mb-2 block font-medium">Response headers to check:</p>
Response headers to check: <ul className="mt-0 mr-0 mb-3 ml-0 list-disc pl-5">
</Typography.Text>
<ul style={{ paddingLeft: 20, margin: "0 0 12px 0" }}>
<li> <li>
<Typography.Text>x-litellm-semantic-filter: shows total tools → selected tools</Typography.Text> <span>x-litellm-semantic-filter: shows total tools → selected tools</span>
<Typography.Text type="secondary" style={{ display: "block" }}> <span className="block text-sm text-muted-foreground">Example: 10→3</span>
Example: 10→3
</Typography.Text>
</li> </li>
<li> <li>
<Typography.Text>x-litellm-semantic-filter-tools: CSV of selected tool names</Typography.Text> <span>x-litellm-semantic-filter-tools: CSV of selected tool names</span>
<Typography.Text type="secondary" style={{ display: "block" }}> <span className="block text-sm text-muted-foreground">
Example: wikipedia-fetch,github-search,slack-post Example: wikipedia-fetch,github-search,slack-post
</Typography.Text> </span>
</li> </li>
</ul> </ul>
<pre <pre className="m-0 overflow-auto rounded-sm bg-muted p-3 text-xs">{curlCommand}</pre>
style={{
background: "#f5f5f5",
padding: 12,
borderRadius: 4,
overflow: "auto",
fontSize: 12,
margin: 0,
}}
>
{curlCommand}
</pre>
</div> </div>
), </TabsContent>
}, </Tabs>
]} </CardContent>
/>
</Card> </Card>
); );
} }

View file

@ -0,0 +1,197 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ToolDetail } from "./ToolDetail";
import {
deleteToolPolicyOverride,
fetchToolDetail,
fetchToolPolicyOptions,
getToolUsageLogs,
keyListCall,
teamListCall,
updateToolPolicy,
type ToolDetailResponse,
type ToolPolicyOption,
type ToolPolicyOverrideRow,
type ToolRow,
type ToolUsageLogsResponse,
} from "@/components/networking";
vi.mock("@/components/networking", () => ({
deleteToolPolicyOverride: vi.fn(),
fetchToolDetail: vi.fn(),
fetchToolPolicyOptions: vi.fn(),
getToolUsageLogs: vi.fn(),
keyListCall: vi.fn(),
teamListCall: vi.fn(),
updateToolPolicy: vi.fn(),
}));
vi.mock("@/components/common_components/team_dropdown", () => ({
default: ({ onChange }: { onChange: (id: string) => void }) => (
<button type="button" onClick={() => onChange("team-1")}>
pick team
</button>
),
}));
vi.mock("@/components/GuardrailsMonitor/LogViewer", () => ({
LogViewer: ({ totalLogs }: { totalLogs: number }) => <div>log viewer ({totalLogs})</div>,
}));
const detail = {
tool: {
tool_name: "search_docs",
input_policy: "untrusted",
output_policy: "trusted",
origin: "mcp",
call_count: 42,
user_agent: "litellm-python/1.0",
created_at: "2026-03-04T10:00:00Z",
},
overrides: [],
} as unknown as ToolDetailResponse;
const renderDetail = (onBack = vi.fn()) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
return render(
<QueryClientProvider client={queryClient}>
<ToolDetail toolName="search_docs" onBack={onBack} accessToken="tok" />
</QueryClientProvider>,
);
};
describe("ToolDetail", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetchToolDetail).mockResolvedValue(detail);
vi.mocked(fetchToolPolicyOptions).mockResolvedValue({ input_policies: [], output_policies: [] });
vi.mocked(teamListCall).mockResolvedValue({ data: [] });
vi.mocked(keyListCall).mockResolvedValue({ keys: [] });
vi.mocked(getToolUsageLogs).mockResolvedValue({ logs: [], total: 0 } as unknown as ToolUsageLogsResponse);
vi.mocked(updateToolPolicy).mockResolvedValue(undefined as unknown as ToolRow);
vi.mocked(deleteToolPolicyOverride).mockResolvedValue(
undefined as unknown as { deleted: boolean; tool_name: string },
);
});
it("shows the tool identity once loaded", async () => {
renderDetail();
expect(await screen.findByText("search_docs")).toBeInTheDocument();
expect(screen.getByText("mcp")).toBeInTheDocument();
expect(screen.getByText("42 calls")).toBeInTheDocument();
expect(screen.getByText("litellm-python/1.0")).toBeInTheDocument();
});
it("renders both policy panels with the tool's current policies", async () => {
renderDetail();
expect(await screen.findByText("Input Policy")).toBeInTheDocument();
expect(screen.getByText("Output Policy")).toBeInTheDocument();
expect(screen.getByText("untrusted")).toBeInTheDocument();
expect(screen.getByText("trusted")).toBeInTheDocument();
});
it("uses the policy option descriptions when the backend supplies them", async () => {
vi.mocked(fetchToolPolicyOptions).mockResolvedValue({
input_policies: [{ value: "untrusted", description: "Treat inputs as hostile" } as ToolPolicyOption],
output_policies: [{ value: "trusted", description: "Outputs may be chained" } as ToolPolicyOption],
});
renderDetail();
expect(await screen.findByText("Treat inputs as hostile")).toBeInTheDocument();
expect(screen.getByText("Outputs may be chained")).toBeInTheDocument();
});
it("returns to the list when Back is pressed", async () => {
const onBack = vi.fn();
renderDetail(onBack);
await userEvent.click(await screen.findByRole("button", { name: /Back to Tool Policies/ }));
expect(onBack).toHaveBeenCalled();
});
it("reports a failed detail load and still offers a way back", async () => {
vi.mocked(fetchToolDetail).mockRejectedValue(new Error("nope"));
renderDetail();
expect(await screen.findByText("Failed to load tool details.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Back to Tool Policies/ })).toBeInTheDocument();
});
it("hides the overrides panel when the tool has none", async () => {
renderDetail();
await screen.findByText("Input Policy");
expect(screen.queryByText("Blocked for team or key")).not.toBeInTheDocument();
});
it("lists existing overrides and removes the chosen one", async () => {
vi.mocked(fetchToolDetail).mockResolvedValue({
...detail,
overrides: [
{
override_id: "o1",
team_id: "team-alpha",
key_hash: null,
key_alias: null,
} as unknown as ToolPolicyOverrideRow,
],
});
renderDetail();
expect(await screen.findByText("Team: team-alpha")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Remove" }));
await waitFor(() =>
expect(deleteToolPolicyOverride).toHaveBeenCalledWith("tok", "search_docs", {
team_id: "team-alpha",
key_hash: undefined,
}),
);
});
it("keeps the block button disabled until a team is chosen, then blocks that team", async () => {
renderDetail();
const blockButton = await screen.findByRole("button", { name: /Block for team/ });
expect(blockButton).toBeDisabled();
await userEvent.click(screen.getByRole("button", { name: "pick team" }));
await waitFor(() => expect(screen.getByRole("button", { name: /Block for team/ })).toBeEnabled());
await userEvent.click(screen.getByRole("button", { name: /Block for team/ }));
await waitFor(() =>
expect(updateToolPolicy).toHaveBeenCalledWith(
"tok",
"search_docs",
{ input_policy: "blocked" },
{ team_id: "team-1", key_hash: undefined, key_alias: undefined },
),
);
});
it("switches the block scope to a key", async () => {
renderDetail();
await screen.findByText("Block for team or key");
await userEvent.click(screen.getByRole("radio", { name: "Key" }));
expect(await screen.findByRole("button", { name: /Block for key/ })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "pick team" })).not.toBeInTheDocument();
});
it("passes the usage-log total through to the log viewer", async () => {
vi.mocked(getToolUsageLogs).mockResolvedValue({ logs: [], total: 7 } as unknown as ToolUsageLogsResponse);
renderDetail();
expect(await screen.findByText("log viewer (7)")).toBeInTheDocument();
});
});

View file

@ -1,9 +1,19 @@
"use client"; "use client";
import { ArrowLeftOutlined, HistoryOutlined, ToolOutlined } from "@ant-design/icons"; import { ArrowLeft, History, Wrench } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button, Select, Spin } from "antd"; import React, { useCallback, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import TeamDropdown from "@/components/common_components/team_dropdown"; import TeamDropdown from "@/components/common_components/team_dropdown";
import { LogViewer } from "@/components/GuardrailsMonitor/LogViewer"; import { LogViewer } from "@/components/GuardrailsMonitor/LogViewer";
import type { LogEntry } from "@/components/GuardrailsMonitor/mockData"; import type { LogEntry } from "@/components/GuardrailsMonitor/mockData";
@ -32,6 +42,11 @@ interface KeyOption {
key_alias?: string; key_alias?: string;
} }
interface KeyItem {
value: string;
label: string;
}
const TOOL_DETAIL_QUERY_KEY = "tool-detail"; const TOOL_DETAIL_QUERY_KEY = "tool-detail";
const LOGS_PAGE_SIZE = 50; const LOGS_PAGE_SIZE = 50;
@ -133,6 +148,11 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
})); }));
}, [keysData]); }, [keysData]);
const keyItems: KeyItem[] = useMemo(
() => keys.map((k) => ({ value: k.token, label: k.key_alias || k.token?.substring?.(0, 12) || k.token })),
[keys],
);
const invalidateDetail = useCallback(() => { const invalidateDetail = useCallback(() => {
queryClient.invalidateQueries({ queryKey: [TOOL_DETAIL_QUERY_KEY, toolName] }); queryClient.invalidateQueries({ queryKey: [TOOL_DETAIL_QUERY_KEY, toolName] });
}, [queryClient, toolName]); }, [queryClient, toolName]);
@ -218,7 +238,7 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
if (detailLoading && !detail) { if (detailLoading && !detail) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Spin size="large" /> <UiLoadingSpinner className="size-8 text-muted-foreground" />
</div> </div>
); );
} }
@ -226,10 +246,11 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
if (detailError && !detail) { if (detailError && !detail) {
return ( return (
<div> <div>
<Button type="link" icon={<ArrowLeftOutlined />} onClick={onBack} className="pl-0 mb-4"> <Button variant="link" onClick={onBack} className="mb-4 pl-0">
<ArrowLeft />
Back to Tool Policies Back to Tool Policies
</Button> </Button>
<p className="text-red-600">Failed to load tool details.</p> <p className="text-destructive">Failed to load tool details.</p>
</div> </div>
); );
} }
@ -246,40 +267,37 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
return ( return (
<div> <div>
<div className="mb-6"> <div className="mb-6">
<Button type="link" icon={<ArrowLeftOutlined />} onClick={onBack} className="pl-0 mb-4"> <Button variant="link" onClick={onBack} className="mb-4 pl-0">
<ArrowLeft />
Back to Tool Policies Back to Tool Policies
</Button> </Button>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<div className="flex items-center gap-3 mb-1 flex-wrap"> <div className="mb-1 flex flex-wrap items-center gap-3">
<ToolOutlined className="text-xl text-gray-400" /> <Wrench className="size-5 text-muted-foreground" />
<h1 className="text-xl font-semibold text-gray-900 font-mono">{tool.tool_name}</h1> <h1 className="font-mono text-xl font-semibold">{tool.tool_name}</h1>
<span className="inline-flex items-center px-2.5 py-1 text-xs font-medium rounded-md bg-gray-100 text-gray-700 border border-gray-200"> <Badge variant="outline">{tool.origin ?? "—"}</Badge>
{tool.origin ?? "—"} <Badge variant="secondary">{(tool.call_count ?? 0).toLocaleString()} calls</Badge>
</span>
<span className="inline-flex items-center px-2.5 py-1 text-xs font-medium rounded-md bg-indigo-50 text-indigo-700 border border-indigo-200">
{(tool.call_count ?? 0).toLocaleString()} calls
</span>
</div> </div>
<dl className="mt-3 flex flex-wrap gap-x-6 gap-y-1 text-sm text-gray-600"> <dl className="mt-3 flex flex-wrap gap-x-6 gap-y-1 text-sm text-muted-foreground">
{tool.user_agent && ( {tool.user_agent && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<dt className="font-medium text-gray-500 whitespace-nowrap">User Agent:</dt> <dt className="font-medium whitespace-nowrap">User Agent:</dt>
<dd className="font-mono truncate max-w-[40ch]" title={tool.user_agent}> <dd className="max-w-[40ch] truncate font-mono" title={tool.user_agent}>
{tool.user_agent} {tool.user_agent}
</dd> </dd>
</div> </div>
)} )}
{tool.created_at && ( {tool.created_at && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<dt className="font-medium text-gray-500 whitespace-nowrap">First Discovered:</dt> <dt className="font-medium whitespace-nowrap">First Discovered:</dt>
<dd>{new Date(tool.created_at).toLocaleString()}</dd> <dd>{new Date(tool.created_at).toLocaleString()}</dd>
</div> </div>
)} )}
{tool.last_used_at && ( {tool.last_used_at && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<dt className="font-medium text-gray-500 whitespace-nowrap">Last Used:</dt> <dt className="font-medium whitespace-nowrap">Last Used:</dt>
<dd>{new Date(tool.last_used_at).toLocaleString()}</dd> <dd>{new Date(tool.last_used_at).toLocaleString()}</dd>
</div> </div>
)} )}
@ -291,9 +309,9 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
<div className="space-y-6"> <div className="space-y-6">
{/* Two-panel policy layout */} {/* Two-panel policy layout */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<section className="bg-white rounded-lg border border-gray-200 p-5 shadow-xs"> <section className="rounded-lg border border-border bg-card p-5 shadow-xs">
<h2 className="text-sm font-semibold text-gray-700 mb-1">Input Policy</h2> <h2 className="mb-1 text-sm font-semibold">Input Policy</h2>
<p className="text-xs text-gray-500 mb-3"> <p className="mb-3 text-xs text-muted-foreground">
{inputDesc ?? "Controls what data this tool is allowed to accept."} {inputDesc ?? "Controls what data this tool is allowed to accept."}
</p> </p>
<PolicySelect <PolicySelect
@ -308,9 +326,9 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
/> />
</section> </section>
<section className="bg-white rounded-lg border border-gray-200 p-5 shadow-xs"> <section className="rounded-lg border border-border bg-card p-5 shadow-xs">
<h2 className="text-sm font-semibold text-gray-700 mb-1">Output Policy</h2> <h2 className="mb-1 text-sm font-semibold">Output Policy</h2>
<p className="text-xs text-gray-500 mb-3"> <p className="mb-3 text-xs text-muted-foreground">
{outputDesc ?? "Controls how this tool's output is trusted by downstream tools."} {outputDesc ?? "Controls how this tool's output is trusted by downstream tools."}
</p> </p>
<PolicySelect <PolicySelect
@ -327,24 +345,18 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
</div> </div>
{overrides.length > 0 && ( {overrides.length > 0 && (
<section className="bg-white rounded-lg border border-gray-200 p-5 shadow-xs"> <section className="rounded-lg border border-border bg-card p-5 shadow-xs">
<h2 className="text-sm font-semibold text-gray-700 mb-3">Blocked for team or key</h2> <h2 className="mb-3 text-sm font-semibold">Blocked for team or key</h2>
<ul className="border rounded-md divide-y divide-gray-100 bg-red-50/30"> <ul className="divide-y divide-border rounded-md border border-border">
{overrides.map((ov) => ( {overrides.map((ov) => (
<li key={ov.override_id} className="flex items-center justify-between px-3 py-2.5 text-sm"> <li key={ov.override_id} className="flex items-center justify-between px-3 py-2.5 text-sm">
<span className="text-gray-700"> <span>
{ov.team_id ? `Team: ${ov.team_id}` : ""} {ov.team_id ? `Team: ${ov.team_id}` : ""}
{ov.team_id && ov.key_hash ? " · " : ""} {ov.team_id && ov.key_hash ? " · " : ""}
{ov.key_hash ? `Key: ${ov.key_alias || ov.key_hash.substring(0, 8)}` : ""} {ov.key_hash ? `Key: ${ov.key_alias || ov.key_hash.substring(0, 8)}` : ""}
{!ov.team_id && !ov.key_hash ? "—" : ""} {!ov.team_id && !ov.key_hash ? "—" : ""}
</span> </span>
<Button <Button variant="link" size="sm" disabled={overrideSaving} onClick={() => handleRemoveOverride(ov)}>
type="link"
danger
size="small"
disabled={overrideSaving}
onClick={() => handleRemoveOverride(ov)}
>
Remove Remove
</Button> </Button>
</li> </li>
@ -353,13 +365,13 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
</section> </section>
)} )}
<section className="bg-white rounded-lg border border-gray-200 p-5 shadow-xs"> <section className="rounded-lg border border-border bg-card p-5 shadow-xs">
<h2 className="text-sm font-semibold text-gray-700 mb-3">Block for team or key</h2> <h2 className="mb-3 text-sm font-semibold">Block for team or key</h2>
<div className="flex flex-col gap-4 max-w-md"> <div className="flex max-w-md flex-col gap-4">
<div> <div>
<span className="text-sm font-medium text-gray-700 block mb-2">Scope</span> <span className="mb-2 block text-sm font-medium">Scope</span>
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700"> <label className="flex cursor-pointer items-center gap-2 text-sm">
<input <input
type="radio" type="radio"
checked={blockScope === "team"} checked={blockScope === "team"}
@ -368,7 +380,7 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
/> />
Team Team
</label> </label>
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700"> <label className="flex cursor-pointer items-center gap-2 text-sm">
<input <input
type="radio" type="radio"
checked={blockScope === "key"} checked={blockScope === "key"}
@ -380,36 +392,34 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
</div> </div>
</div> </div>
<div> <div>
<span className="text-sm font-medium text-gray-700 block mb-2"> <span className="mb-2 block text-sm font-medium">{blockScope === "team" ? "Team" : "Key"}</span>
{blockScope === "team" ? "Team" : "Key"}
</span>
{blockScope === "team" ? ( {blockScope === "team" ? (
<TeamDropdown value={blockTeamId ?? undefined} onChange={(id) => setBlockTeamId(id || null)} /> <TeamDropdown value={blockTeamId ?? undefined} onChange={(id) => setBlockTeamId(id || null)} />
) : ( ) : (
<Select <Combobox
placeholder="Select key" items={keyItems}
allowClear value={keyItems.find((k) => k.value === blockKey?.token) ?? null}
showSearch onValueChange={(item: KeyItem | null) =>
optionFilterProp="label" setBlockKey(keys.find((k) => k.token === item?.value) ?? null)
value={blockKey ? blockKey.token : undefined} }
onChange={(token) => { >
const k = keys.find((x) => x.token === token); <ComboboxInput placeholder="Select key" showClear className="w-full min-w-50" />
setBlockKey(k ?? null); <ComboboxContent>
}} <ComboboxEmpty>No keys found</ComboboxEmpty>
options={keys.map((k) => ({ <ComboboxList>
value: k.token, {(item: KeyItem) => (
label: k.key_alias || k.token?.substring?.(0, 12) || k.token, <ComboboxItem key={item.value} value={item}>
}))} {item.label}
className="w-full" </ComboboxItem>
style={{ minWidth: 200 }} )}
/> </ComboboxList>
</ComboboxContent>
</Combobox>
)} )}
</div> </div>
<Button <Button
type="primary" variant="destructive"
danger
disabled={overrideSaving || (blockScope === "team" ? !blockTeamId : !blockKey?.token)} disabled={overrideSaving || (blockScope === "team" ? !blockTeamId : !blockKey?.token)}
loading={overrideSaving}
onClick={handleAddOverride} onClick={handleAddOverride}
> >
Block for {blockScope} Block for {blockScope}
@ -417,9 +427,9 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
</div> </div>
</section> </section>
<section className="bg-white rounded-lg border border-gray-200 p-5 shadow-xs"> <section className="rounded-lg border border-border bg-card p-5 shadow-xs">
<h2 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2"> <h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
<HistoryOutlined /> <History className="size-4" />
Recent logs Recent logs
</h2> </h2>
<LogViewer <LogViewer

View file

@ -30,12 +30,12 @@ describe("PolicySelect", () => {
it("should be disabled when saving is true", () => { it("should be disabled when saving is true", () => {
renderWithProviders(<PolicySelect value="untrusted" toolName="test-tool" saving={true} onChange={vi.fn()} />); renderWithProviders(<PolicySelect value="untrusted" toolName="test-tool" saving={true} onChange={vi.fn()} />);
expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false"); expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false");
expect(screen.getByRole("combobox").closest(".ant-select")).toHaveClass("ant-select-disabled"); expect(screen.getByRole("combobox")).toBeDisabled();
}); });
it("should not be disabled when saving is false", () => { it("should not be disabled when saving is false", () => {
renderWithProviders(<PolicySelect value="untrusted" toolName="test-tool" saving={false} onChange={vi.fn()} />); renderWithProviders(<PolicySelect value="untrusted" toolName="test-tool" saving={false} onChange={vi.fn()} />);
expect(screen.getByRole("combobox").closest(".ant-select")).not.toHaveClass("ant-select-disabled"); expect(screen.getByRole("combobox")).toBeEnabled();
}); });
}); });

View file

@ -1,17 +1,18 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { Select } from "antd"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/cva.config";
export const INPUT_POLICY_OPTIONS = [ export const INPUT_POLICY_OPTIONS = [
{ value: "untrusted", label: "untrusted", color: "#92400e", bg: "#fef3c7", border: "#fcd34d" }, { value: "untrusted", label: "untrusted", dot: "bg-amber-500" },
{ value: "trusted", label: "trusted", color: "#065f46", bg: "#d1fae5", border: "#6ee7b7" }, { value: "trusted", label: "trusted", dot: "bg-green-500" },
{ value: "blocked", label: "blocked", color: "#991b1b", bg: "#fee2e2", border: "#fca5a5" }, { value: "blocked", label: "blocked", dot: "bg-red-500" },
] as const; ] as const;
export const OUTPUT_POLICY_OPTIONS = [ export const OUTPUT_POLICY_OPTIONS = [
{ value: "untrusted", label: "untrusted", color: "#92400e", bg: "#fef3c7", border: "#fcd34d" }, { value: "untrusted", label: "untrusted", dot: "bg-amber-500" },
{ value: "trusted", label: "trusted", color: "#065f46", bg: "#d1fae5", border: "#6ee7b7" }, { value: "trusted", label: "trusted", dot: "bg-green-500" },
] as const; ] as const;
export const POLICY_OPTIONS = INPUT_POLICY_OPTIONS; export const POLICY_OPTIONS = INPUT_POLICY_OPTIONS;
@ -36,56 +37,30 @@ export const PolicySelect: React.FC<PolicySelectProps> = ({
onChange, onChange,
policyType = "input", policyType = "input",
size = "small", size = "small",
minWidth = 110,
stopPropagation = true, stopPropagation = true,
}) => { }) => {
const options = policyType === "output" ? OUTPUT_POLICY_OPTIONS : INPUT_POLICY_OPTIONS; const options = policyType === "output" ? OUTPUT_POLICY_OPTIONS : INPUT_POLICY_OPTIONS;
const style = policyStyle(value); const selected = policyStyle(value);
return ( return (
<Select <Select value={value} disabled={saving} onValueChange={(v: string | null) => v !== null && onChange(toolName, v)}>
size={size} <SelectTrigger
value={value} size={size === "small" ? "sm" : "default"}
disabled={saving} className="w-auto min-w-28"
loading={saving}
onChange={(v) => onChange(toolName, v)}
onClick={(e) => stopPropagation && e.stopPropagation()} onClick={(e) => stopPropagation && e.stopPropagation()}
style={{
minWidth,
fontWeight: 500,
backgroundColor: style.bg,
borderColor: style.border,
color: style.color,
borderRadius: 999,
fontSize: size === "small" ? 11 : 12,
}}
popupMatchSelectWidth={false}
options={options.map((o) => ({
value: o.value,
label: (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 12,
fontWeight: 500,
color: o.color,
}}
> >
<span <span className={cn("size-2 shrink-0 rounded-full", selected.dot)} />
style={{ <SelectValue />
width: 8, </SelectTrigger>
height: 8, <SelectContent>
borderRadius: "50%", {options.map((o) => (
backgroundColor: o.color, <SelectItem key={o.value} value={o.value}>
display: "inline-block", <span className="inline-flex items-center gap-1.5">
flexShrink: 0, <span className={cn("size-2 shrink-0 rounded-full", o.dot)} />
}}
/>
{o.label} {o.label}
</span> </span>
), </SelectItem>
}))} ))}
/> </SelectContent>
</Select>
); );
}; };

View file

@ -67,21 +67,27 @@ const row = (toolId: string): HTMLElement => {
const policySelect = (toolId: string, kind: "input" | "output"): HTMLElement => const policySelect = (toolId: string, kind: "input" | "output"): HTMLElement =>
within(row(toolId)).getAllByRole("combobox")[kind === "input" ? 0 : 1]; within(row(toolId)).getAllByRole("combobox")[kind === "input" ? 0 : 1];
/** Exact selected-value text. Never assert with toHaveTextContent here: it substring-matches, so "untrusted" satisfies "trusted". */ /**
const policyValue = (toolId: string, kind: "input" | "output"): string => * Exact selected-value text, read off the policy cell and stripped of anything
policySelect(toolId, kind).closest(".ant-select")?.querySelector(".ant-select-selection-item")?.textContent ?? ""; * that is not a letter (the control draws a status dot and a chevron around the
* label). Never assert with toHaveTextContent here: it substring-matches, so
* "untrusted" satisfies "trusted".
*/
const policyValue = (toolId: string, kind: "input" | "output"): string => {
const cell = policySelect(toolId, kind).closest("td");
return (cell?.textContent ?? "").replace(/[^a-z]/gi, "");
};
const isSaving = (toolId: string, kind: "input" | "output"): boolean => const isSaving = (toolId: string, kind: "input" | "output"): boolean =>
policySelect(toolId, kind).closest(".ant-select")?.classList.contains("ant-select-disabled") ?? false; policySelect(toolId, kind).hasAttribute("disabled");
const chooseOption = async (user: ReturnType<typeof userEvent.setup>, trigger: HTMLElement, label: string) => { const chooseOption = async (user: ReturnType<typeof userEvent.setup>, trigger: HTMLElement, label: string) => {
await user.click(trigger); await user.click(trigger);
// The label also renders in the trigger once selected, so take the last match:
// the popup is portalled after the table in document order.
const option = await waitFor(() => { const option = await waitFor(() => {
const match = Array.from(document.querySelectorAll(".ant-select-item-option")).find( const matches = screen.getAllByText(label);
(element) => element.textContent === label, return matches[matches.length - 1];
);
if (match === undefined) throw new Error(`option ${label} not open`);
return match as HTMLElement;
}); });
await user.click(option); await user.click(option);
}; };

View file

@ -0,0 +1,126 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { flexRender, getCoreRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
import { getToolPoliciesTableColumns } from "./ToolPoliciesTableColumns";
import type { ToolRow } from "@/components/networking";
const row: ToolRow = {
tool_name: "search_docs",
input_policy: "untrusted",
output_policy: "trusted",
call_count: 1234,
team_id: "team-alpha",
key_hash: "abc123def456",
key_alias: "prod-key",
user_agent: "litellm-python/1.0",
created_at: "2026-03-04T10:00:00Z",
} as ToolRow;
const defaultDeps = {
onSelectTool: vi.fn(),
savingInput: new Set<string>(),
savingOutput: new Set<string>(),
onInputPolicyChange: vi.fn(),
onOutputPolicyChange: vi.fn(),
};
// Renders the column definitions through a real TanStack table so each `cell`
// renderer runs exactly as the DataTable runs it.
function TableHarness({ columns, data }: { columns: ColumnDef<ToolRow>[]; data: ToolRow[] }) {
const table = useReactTable({ columns, data, getCoreRowModel: getCoreRowModel() });
return (
<table>
<tbody>
{table.getRowModel().rows.map((r) => (
<tr key={r.id}>
{r.getVisibleCells().map((cell) => (
<td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
))}
</tr>
))}
</tbody>
</table>
);
}
const renderTable = (deps = {}, data: ToolRow[] = [row]) =>
render(<TableHarness columns={getToolPoliciesTableColumns({ ...defaultDeps, ...deps })} data={data} />);
describe("getToolPoliciesTableColumns", () => {
it("defines the expected columns in order", () => {
const columns = getToolPoliciesTableColumns(defaultDeps);
expect(columns.map((c) => c.id)).toEqual([
"created_at",
"tool_name",
"input_policy",
"output_policy",
"call_count",
"team_id",
"key_hash",
"key_alias",
"user_agent",
]);
});
it("renders the row's identifying fields", () => {
renderTable();
expect(screen.getByText("search_docs")).toBeInTheDocument();
expect(screen.getByText("team-alpha")).toBeInTheDocument();
expect(screen.getByText("prod-key")).toBeInTheDocument();
expect(screen.getByText("litellm-python/1.0")).toBeInTheDocument();
});
it("formats the call count with thousands separators", () => {
renderTable();
expect(screen.getByText("1,234")).toBeInTheDocument();
});
it("renders a zero call count rather than a blank cell", () => {
renderTable({}, [{ ...row, call_count: undefined } as ToolRow]);
expect(screen.getByText("0")).toBeInTheDocument();
});
it("falls back to a dash for a missing key alias and user agent", () => {
renderTable({}, [{ ...row, key_alias: undefined, user_agent: undefined } as ToolRow]);
expect(screen.getAllByText("-").length).toBeGreaterThanOrEqual(2);
});
it("notifies the caller when the tool name is clicked", async () => {
const onSelectTool = vi.fn();
renderTable({ onSelectTool });
await userEvent.click(screen.getByText("search_docs"));
expect(onSelectTool).toHaveBeenCalledWith("search_docs");
});
it("renders a policy control for each direction, showing the row's current policies", () => {
renderTable();
expect(screen.getByText("untrusted")).toBeInTheDocument();
expect(screen.getByText("trusted")).toBeInTheDocument();
expect(screen.getAllByRole("combobox")).toHaveLength(2);
});
it("disables only the input policy control while that direction is saving", () => {
renderTable({ savingInput: new Set(["search_docs"]) });
const [input, output] = screen.getAllByRole("combobox");
expect(input).toBeDisabled();
expect(output).toBeEnabled();
});
it("disables only the output policy control while that direction is saving", () => {
renderTable({ savingOutput: new Set(["search_docs"]) });
const [input, output] = screen.getAllByRole("combobox");
expect(input).toBeEnabled();
expect(output).toBeDisabled();
});
});

View file

@ -1,11 +1,11 @@
"use client"; "use client";
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { Tooltip } from "antd";
import { ToolRow } from "@/components/networking"; import { ToolRow } from "@/components/networking";
import { DataTableSortHeader } from "@/components/shared/DataTable"; import { DataTableSortHeader } from "@/components/shared/DataTable";
import { DateCell, IdCell, IdentityCell } from "@/components/shared/table_cells"; import { DateCell, IdCell, IdentityCell } from "@/components/shared/table_cells";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { PolicySelect } from "./PolicySelect"; import { PolicySelect } from "./PolicySelect";
@ -20,9 +20,12 @@ interface ToolPoliciesTableColumnsDeps {
function TruncatedText({ value, className }: { value: string | undefined; className?: string }) { function TruncatedText({ value, className }: { value: string | undefined; className?: string }) {
const text = value ?? "-"; const text = value ?? "-";
return ( return (
<Tooltip title={text}> <TooltipProvider>
<span className={className}>{text}</span> <Tooltip>
<TooltipTrigger render={<span className={className}>{text}</span>} />
<TooltipContent>{text}</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider>
); );
} }

View file

@ -1,7 +1,7 @@
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { cx } from "@/lib/cva.config"; import { cx } from "@/lib/cva.config";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { Input } from "antd";
import { LucideIcon } from "lucide-react"; import { LucideIcon } from "lucide-react";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
@ -30,12 +30,13 @@ export const FilterInput: React.FC<FilterInputProps> = ({ placeholder, value, on
}; };
return ( return (
<Input <InputGroup className={cx("w-64", className)}>
placeholder={placeholder} {Icon && (
value={localValue} <InputGroupAddon>
onChange={handleChange} <Icon className="size-4 text-muted-foreground" />
prefix={Icon ? <Icon size={16} className="text-gray-500" /> : undefined} </InputGroupAddon>
className={cx("w-64", className)} )}
/> <InputGroupInput placeholder={placeholder} value={localValue} onChange={handleChange} />
</InputGroup>
); );
}; };

View file

@ -21,12 +21,18 @@ describe("FiltersButton", () => {
expect(onClick).toHaveBeenCalledTimes(1); expect(onClick).toHaveBeenCalledTimes(1);
}); });
it("should show badge when hasActiveFilters is true", () => { it("should show the active-filter indicator when hasActiveFilters is true", () => {
const onClick = vi.fn(); const onClick = vi.fn();
const { container } = render(<FiltersButton onClick={onClick} active={false} hasActiveFilters={true} />); const { container } = render(<FiltersButton onClick={onClick} active={false} hasActiveFilters={true} />);
const button = screen.getByRole("button", { name: /filters/i }); expect(screen.getByRole("button", { name: /filters/i })).toBeInTheDocument();
const badgeWrapper = button.closest(".ant-badge"); expect(container.querySelector("sup")).toBeInTheDocument();
expect(badgeWrapper).toBeInTheDocument(); });
it("should not show the active-filter indicator when hasActiveFilters is false", () => {
const onClick = vi.fn();
const { container } = render(<FiltersButton onClick={onClick} active={false} hasActiveFilters={false} />);
expect(screen.getByRole("button", { name: /filters/i })).toBeInTheDocument();
expect(container.querySelector("sup")).not.toBeInTheDocument();
}); });
it("should render custom label when provided", () => { it("should render custom label when provided", () => {

View file

@ -1,4 +1,5 @@
import { Badge, Button } from "antd"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/cva.config";
import { Filter } from "lucide-react"; import { Filter } from "lucide-react";
import React from "react"; import React from "react";
@ -16,10 +17,14 @@ export const FiltersButton: React.FC<FiltersButtonProps> = ({
label = "Filters", label = "Filters",
}) => { }) => {
return ( return (
<Badge color="blue" dot={hasActiveFilters}> <span className="relative inline-flex">
<Button type="default" onClick={onClick} icon={<Filter size={16} />} className={active ? "bg-gray-100" : ""}> <Button variant="outline" onClick={onClick} className={cn(active && "bg-muted")}>
<Filter className="size-4" />
{label} {label}
</Button> </Button>
</Badge> {hasActiveFilters && (
<sup aria-hidden="true" className="absolute -top-0.5 -right-0.5 size-1.5 rounded-full bg-primary" />
)}
</span>
); );
}; };

View file

@ -1,4 +1,4 @@
import { Button } from "antd"; import { Button } from "@/components/ui/button";
import { RotateCcw } from "lucide-react"; import { RotateCcw } from "lucide-react";
import React from "react"; import React from "react";
@ -9,7 +9,8 @@ interface ResetFiltersButtonProps {
export const ResetFiltersButton: React.FC<ResetFiltersButtonProps> = ({ onClick, label = "Reset Filters" }) => { export const ResetFiltersButton: React.FC<ResetFiltersButtonProps> = ({ onClick, label = "Reset Filters" }) => {
return ( return (
<Button type="default" onClick={onClick} icon={<RotateCcw size={16} />}> <Button variant="outline" onClick={onClick}>
<RotateCcw className="size-4" />
{label} {label}
</Button> </Button>
); );

View file

@ -0,0 +1,132 @@
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "@/../tests/test-utils";
import EmailEventSettings from "./email_event_settings";
const { getEmailEventSettings, updateEmailEventSettings, resetEmailEventSettings } = vi.hoisted(() => ({
getEmailEventSettings: vi.fn(),
updateEmailEventSettings: vi.fn(),
resetEmailEventSettings: vi.fn(),
}));
vi.mock("@/components/networking", () => ({
getEmailEventSettings,
updateEmailEventSettings,
resetEmailEventSettings,
}));
const settingsResponse = {
settings: [
{ event: "Virtual Key Created", enabled: true },
{ event: "New User Invitation", enabled: false },
],
};
describe("EmailEventSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
getEmailEventSettings.mockResolvedValue(settingsResponse);
updateEmailEventSettings.mockResolvedValue({});
resetEmailEventSettings.mockResolvedValue({});
});
it("renders the heading and the explanatory copy", async () => {
renderWithProviders(<EmailEventSettings accessToken="sk-test" />);
expect(await screen.findByText("Email Notifications")).toBeInTheDocument();
expect(screen.getByText("Select which events should trigger email notifications.")).toBeInTheDocument();
});
it("renders one checkbox per event, reflecting the persisted enabled state", async () => {
renderWithProviders(<EmailEventSettings accessToken="sk-test" />);
await screen.findByText("Virtual Key Created");
const checkboxes = screen.getAllByRole("checkbox");
expect(checkboxes).toHaveLength(2);
expect(checkboxes[0]).toBeChecked();
expect(checkboxes[1]).not.toBeChecked();
});
it("renders a per-event description", async () => {
renderWithProviders(<EmailEventSettings accessToken="sk-test" />);
expect(
await screen.findByText(/An email will be sent to the user when a new virtual key is created/),
).toBeInTheDocument();
expect(
screen.getByText(/An email will be sent to the email address of the user when a new user is created/),
).toBeInTheDocument();
});
it("saves the toggled enabled flags rather than the originally fetched ones", async () => {
const user = userEvent.setup();
renderWithProviders(<EmailEventSettings accessToken="sk-test" />);
await screen.findByText("Virtual Key Created");
await user.click(screen.getAllByRole("checkbox")[1]);
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(updateEmailEventSettings).toHaveBeenCalledWith("sk-test", {
settings: [
{ event: "Virtual Key Created", enabled: true },
{ event: "New User Invitation", enabled: true },
],
});
});
});
it("unchecking an enabled event is persisted as disabled", async () => {
const user = userEvent.setup();
renderWithProviders(<EmailEventSettings accessToken="sk-test" />);
await screen.findByText("Virtual Key Created");
await user.click(screen.getAllByRole("checkbox")[0]);
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(updateEmailEventSettings).toHaveBeenCalledWith("sk-test", {
settings: [
{ event: "Virtual Key Created", enabled: false },
{ event: "New User Invitation", enabled: false },
],
});
});
});
it("resets to defaults and refetches the settings", async () => {
const user = userEvent.setup();
renderWithProviders(<EmailEventSettings accessToken="sk-test" />);
await screen.findByText("Virtual Key Created");
expect(getEmailEventSettings).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole("button", { name: "Reset to Defaults" }));
await waitFor(() => {
expect(resetEmailEventSettings).toHaveBeenCalledWith("sk-test");
});
await waitFor(() => {
expect(getEmailEventSettings).toHaveBeenCalledTimes(2);
});
});
it("does not render event rows while the fetch is in flight", () => {
getEmailEventSettings.mockReturnValue(new Promise(() => {}));
renderWithProviders(<EmailEventSettings accessToken="sk-test" />);
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save Changes" })).toBeDisabled();
});
it("does not call the API when there is no access token", async () => {
renderWithProviders(<EmailEventSettings accessToken={null} />);
await waitFor(() => {
expect(getEmailEventSettings).not.toHaveBeenCalled();
});
});
});

View file

@ -1,13 +1,14 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Card, Text, Button } from "@tremor/react"; import { Button } from "@/components/ui/button";
import { Typography, Divider, Spin, Checkbox } from "antd"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import NotificationsManager from "../molecules/notifications_manager"; import NotificationsManager from "../molecules/notifications_manager";
import { getEmailEventSettings, updateEmailEventSettings, resetEmailEventSettings } from "../networking"; import { getEmailEventSettings, updateEmailEventSettings, resetEmailEventSettings } from "../networking";
import { EmailEvent } from "../../types"; import { EmailEvent } from "../../types";
import { EmailEventSetting } from "./types"; import { EmailEventSetting } from "./types";
const { Title } = Typography;
interface EmailEventSettingsProps { interface EmailEventSettingsProps {
accessToken: string | null; accessToken: string | null;
} }
@ -88,39 +89,46 @@ const EmailEventSettings: React.FC<EmailEventSettingsProps> = ({ accessToken })
return ( return (
<Card> <Card>
<Title level={4}>Email Notifications</Title> <CardHeader>
<Text>Select which events should trigger email notifications.</Text> <CardTitle className="text-base">Email Notifications</CardTitle>
<Divider /> <p className="text-sm text-muted-foreground">Select which events should trigger email notifications.</p>
</CardHeader>
<CardContent>
<Separator className="mb-6" />
{loading ? ( {loading ? (
<div style={{ textAlign: "center", padding: "20px" }}> <div className="space-y-4">
<Spin size="large" /> <Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{eventSettings.map((setting) => ( {eventSettings.map((setting) => (
<div key={setting.event} className="flex items-center"> <div key={setting.event} className="flex items-start">
<Checkbox <Checkbox
checked={setting.enabled} checked={setting.enabled}
onChange={(e) => handleCheckboxChange(setting.event, e.target.checked)} onCheckedChange={(checked) => handleCheckboxChange(setting.event, checked === true)}
className="mt-1"
/> />
<div className="ml-3"> <div className="ml-3">
<Text>{setting.event}</Text> <p className="text-sm">{setting.event}</p>
<div className="text-sm text-gray-500 block">{getEventDescription(setting.event)}</div> <div className="block text-sm text-muted-foreground">{getEventDescription(setting.event)}</div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
)} )}
<div className="mt-6 flex space-x-4"> <div className="mt-6 flex gap-4">
<Button onClick={handleSaveSettings} disabled={loading}> <Button onClick={handleSaveSettings} disabled={loading}>
Save Changes Save Changes
</Button> </Button>
<Button onClick={handleResetSettings} variant="secondary" disabled={loading}> <Button variant="secondary" onClick={handleResetSettings} disabled={loading}>
Reset to Defaults Reset to Defaults
</Button> </Button>
</div> </div>
</CardContent>
</Card> </Card>
); );
}; };

View file

@ -0,0 +1,127 @@
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "@/../tests/test-utils";
import EmailSettings from "./email_settings";
const { serviceHealthCheck, setCallbacksCall } = vi.hoisted(() => ({
serviceHealthCheck: vi.fn(),
setCallbacksCall: vi.fn(),
}));
vi.mock("@/components/networking", () => ({ serviceHealthCheck, setCallbacksCall }));
vi.mock("./email_events", () => ({
EmailEventSettings: () => <div>email event settings</div>,
}));
const alerts = [
{
name: "email",
variables: {
SMTP_HOST: "smtp.example.com",
SMTP_PORT: "587",
SMTP_PASSWORD: "********",
EMAIL_LOGO_URL: "https://example.com/logo.png",
},
},
{ name: "slack", variables: { SLACK_WEBHOOK_URL: "https://hooks.example.com" } },
];
const inputNamed = (name: string) => document.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
describe("EmailSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
setCallbacksCall.mockResolvedValue({});
serviceHealthCheck.mockResolvedValue({});
});
it("renders the heading and the docs link", () => {
renderWithProviders(<EmailSettings accessToken="sk-test" premiumUser alerts={alerts} />);
expect(screen.getByText("Email Server Settings")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /LiteLLM Docs: email alerts/ })).toHaveAttribute(
"href",
"https://docs.litellm.ai/docs/proxy/email",
);
});
it("renders one named input per email variable and none for other alert types", () => {
renderWithProviders(<EmailSettings accessToken="sk-test" premiumUser alerts={alerts} />);
expect(inputNamed("SMTP_HOST")).toHaveValue("smtp.example.com");
expect(inputNamed("SMTP_PORT")).toHaveValue("587");
expect(inputNamed("SMTP_PASSWORD")).toHaveValue("********");
expect(document.querySelector('input[name="SLACK_WEBHOOK_URL"]')).toBeNull();
});
it("labels each variable and shows its help text", () => {
renderWithProviders(<EmailSettings accessToken="sk-test" premiumUser alerts={alerts} />);
expect(screen.getByText("SMTP_HOST")).toBeInTheDocument();
expect(screen.getByText(/Enter the SMTP host address/)).toBeInTheDocument();
expect(screen.getByText(/Enter the SMTP port number/)).toBeInTheDocument();
});
it("submits only the fields the admin actually edited", async () => {
const user = userEvent.setup();
renderWithProviders(<EmailSettings accessToken="sk-test" premiumUser alerts={alerts} />);
await user.clear(inputNamed("SMTP_HOST"));
await user.type(inputNamed("SMTP_HOST"), "smtp.changed.com");
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(setCallbacksCall).toHaveBeenCalledWith("sk-test", {
general_settings: { alerting: ["email"] },
environment_variables: { SMTP_HOST: "smtp.changed.com" },
});
});
});
it("does not resubmit an untouched masked value", async () => {
const user = userEvent.setup();
renderWithProviders(<EmailSettings accessToken="sk-test" premiumUser alerts={alerts} />);
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(setCallbacksCall).toHaveBeenCalledWith("sk-test", {
general_settings: { alerting: ["email"] },
environment_variables: {},
});
});
});
it("disables the premium-only fields for non-premium users", () => {
renderWithProviders(<EmailSettings accessToken="sk-test" premiumUser={false} alerts={alerts} />);
expect(inputNamed("EMAIL_LOGO_URL")).toBeDisabled();
expect(inputNamed("SMTP_HOST")).not.toBeDisabled();
});
it("leaves the premium-only fields editable for premium users", () => {
renderWithProviders(<EmailSettings accessToken="sk-test" premiumUser alerts={alerts} />);
expect(inputNamed("EMAIL_LOGO_URL")).not.toBeDisabled();
});
it("triggers a live email health check", async () => {
const user = userEvent.setup();
renderWithProviders(<EmailSettings accessToken="sk-test" premiumUser alerts={alerts} />);
await user.click(screen.getByRole("button", { name: "Test Email Alerts" }));
await waitFor(() => {
expect(serviceHealthCheck).toHaveBeenCalledWith("sk-test", "email");
});
});
it("renders the email event settings section", () => {
renderWithProviders(<EmailSettings accessToken="sk-test" premiumUser alerts={alerts} />);
expect(screen.getByText("email event settings")).toBeInTheDocument();
});
});

View file

@ -1,18 +1,34 @@
import React from "react"; import React from "react";
import { Card, Text, Grid, Button, TextInput, TableCell } from "@tremor/react"; import { Button } from "@/components/ui/button";
import { Typography } from "antd"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import NotificationManager from "./molecules/notifications_manager"; import NotificationManager from "./molecules/notifications_manager";
import { serviceHealthCheck, setCallbacksCall } from "./networking"; import { serviceHealthCheck, setCallbacksCall } from "./networking";
import { EmailEventSettings } from "./email_events"; import { EmailEventSettings } from "./email_events";
const { Title } = Typography;
interface EmailSettingsProps { interface EmailSettingsProps {
accessToken: string | null; accessToken: string | null;
premiumUser: boolean; premiumUser: boolean;
alerts: any[]; alerts: any[];
} }
const REQUIRED_MARKER = <span className="text-destructive"> Required * </span>;
const FIELD_HELP: Record<string, React.ReactNode> = {
SMTP_HOST: <>Enter the SMTP host address, e.g. `smtp.resend.com`{REQUIRED_MARKER}</>,
SMTP_PORT: <>Enter the SMTP port number, e.g. `587`{REQUIRED_MARKER}</>,
SMTP_USERNAME: <>Enter the SMTP username, e.g. `username`{REQUIRED_MARKER}</>,
SMTP_PASSWORD: REQUIRED_MARKER,
SMTP_SENDER_EMAIL: <>Enter the sender email address, e.g. `sender@berri.ai`{REQUIRED_MARKER}</>,
TEST_EMAIL_ADDRESS: <>Email Address to send `Test Email Alert` to. example: `info@berri.ai`{REQUIRED_MARKER}</>,
EMAIL_LOGO_URL: <>(Optional) Customize the Logo that appears in the email, pass a url to your logo</>,
EMAIL_SUPPORT_CONTACT: (
<>(Optional) Customize the support email address that appears in the email. Default is support@berri.ai</>
),
};
const PREMIUM_ONLY_FIELDS = ["EMAIL_LOGO_URL", "EMAIL_SUPPORT_CONTACT"];
const EmailSettings: React.FC<EmailSettingsProps> = ({ accessToken, premiumUser, alerts }) => { const EmailSettings: React.FC<EmailSettingsProps> = ({ accessToken, premiumUser, alerts }) => {
const handleSaveEmailSettings = async () => { const handleSaveEmailSettings = async () => {
if (!accessToken) { if (!accessToken) {
@ -62,111 +78,59 @@ const EmailSettings: React.FC<EmailSettingsProps> = ({ accessToken, premiumUser,
<EmailEventSettings accessToken={accessToken} /> <EmailEventSettings accessToken={accessToken} />
</div> </div>
<Card> <Card>
<Title level={4}>Email Server Settings</Title> <CardHeader>
<Text> <CardTitle className="text-base">Email Server Settings</CardTitle>
<a href="https://docs.litellm.ai/docs/proxy/email" target="_blank" style={{ color: "blue" }}> <p className="text-sm">
{" "} <a
href="https://docs.litellm.ai/docs/proxy/email"
target="_blank"
rel="noreferrer"
className="text-primary underline underline-offset-4"
>
LiteLLM Docs: email alerts LiteLLM Docs: email alerts
</a>{" "} </a>
<br /> </p>
</Text> </CardHeader>
<div className="flex w-full"> <CardContent>
{alerts {alerts
.filter((alert) => alert.name === "email") .filter((alert) => alert.name === "email")
.map((alert, index) => ( .map((alert, index) => (
<TableCell key={index}> <div key={index} className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ul> {Object.entries(alert.variables ?? {}).map(([key, value]) => {
<Grid numItems={2}> const isLocked = !premiumUser && PREMIUM_ONLY_FIELDS.includes(key);
{Object.entries(alert.variables ?? {}).map(([key, value]) => ( return (
<li key={key} className="mx-2 my-2"> <div key={key} className="space-y-1">
{premiumUser != true && (key === "EMAIL_LOGO_URL" || key === "EMAIL_SUPPORT_CONTACT") ? ( {isLocked ? (
<div> <a
<a href="https://forms.gle/W3U4PZpJGFHWtHyA9" target="_blank"> href="https://forms.gle/W3U4PZpJGFHWtHyA9"
<Text className="mt-2"> ✨ {key}</Text> target="_blank"
rel="noreferrer"
className="text-sm text-primary underline underline-offset-4"
>
✨ {key}
</a> </a>
<TextInput
name={key}
defaultValue={value as string}
type="password"
disabled={true}
style={{ width: "400px" }}
/>
</div>
) : ( ) : (
<div> <p className="text-sm">{key}</p>
<Text className="mt-2">{key}</Text> )}
<TextInput <Input
name={key} name={key}
defaultValue={value as string} defaultValue={value as string}
type="password" type="password"
style={{ width: "400px" }} disabled={isLocked}
className="max-w-100"
/> />
<div className="text-xs text-muted-foreground italic">{FIELD_HELP[key]}</div>
</div> </div>
)} );
})}
{/* Added descriptions for input fields */}
<p style={{ fontSize: "small", fontStyle: "italic" }}>
{key === "SMTP_HOST" && (
<div style={{ color: "gray" }}>
Enter the SMTP host address, e.g. `smtp.resend.com`
<span style={{ color: "red" }}> Required * </span>
</div> </div>
)}
{key === "SMTP_PORT" && (
<div style={{ color: "gray" }}>
Enter the SMTP port number, e.g. `587`
<span style={{ color: "red" }}> Required * </span>
</div>
)}
{key === "SMTP_USERNAME" && (
<div style={{ color: "gray" }}>
Enter the SMTP username, e.g. `username`
<span style={{ color: "red" }}> Required * </span>
</div>
)}
{key === "SMTP_PASSWORD" && <span style={{ color: "red" }}> Required * </span>}
{key === "SMTP_SENDER_EMAIL" && (
<div style={{ color: "gray" }}>
Enter the sender email address, e.g. `sender@berri.ai`
<span style={{ color: "red" }}> Required * </span>
</div>
)}
{key === "TEST_EMAIL_ADDRESS" && (
<div style={{ color: "gray" }}>
Email Address to send `Test Email Alert` to. example: `info@berri.ai`
<span style={{ color: "red" }}> Required * </span>
</div>
)}
{key === "EMAIL_LOGO_URL" && (
<div style={{ color: "gray" }}>
(Optional) Customize the Logo that appears in the email, pass a url to your logo
</div>
)}
{key === "EMAIL_SUPPORT_CONTACT" && (
<div style={{ color: "gray" }}>
(Optional) Customize the support email address that appears in the email. Default is
support@berri.ai
</div>
)}
</p>
</li>
))} ))}
</Grid>
</ul>
</TableCell>
))}
</div>
<Button className="mt-2" onClick={() => handleSaveEmailSettings()}> <div className="mt-6 flex gap-2">
Save Changes <Button onClick={() => handleSaveEmailSettings()}>Save Changes</Button>
</Button>
<Button <Button
variant="secondary"
onClick={async () => { onClick={async () => {
if (!accessToken) return; if (!accessToken) return;
try { try {
@ -176,10 +140,11 @@ const EmailSettings: React.FC<EmailSettingsProps> = ({ accessToken, premiumUser,
NotificationManager.fromBackend(error); NotificationManager.fromBackend(error);
} }
}} }}
className="mx-2"
> >
Test Email Alerts Test Email Alerts
</Button> </Button>
</div>
</CardContent>
</Card> </Card>
</> </>
); );

View file

@ -73,6 +73,7 @@ const mockUseTeams = vi.fn(() => mockUseTeamsData);
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeams: () => mockUseTeams(), useTeams: () => mockUseTeams(),
useTeam: () => ({ data: undefined }),
})); }));
const mockOrg = { const mockOrg = {
@ -204,3 +205,33 @@ test("should display team ID as fallback when alias is not found", async () => {
expect(screen.getByText("team_999")).toBeInTheDocument(); expect(screen.getByText("team_999")).toBeInTheDocument();
}); });
}); });
test("should keep unsaved settings edits when switching tabs and back", async () => {
mockUseOrganization.mockReturnValue({ data: mockOrg, isLoading: false } as any);
const user = userEvent.setup();
renderWithProviders(
<OrganizationInfoView
organizationId="org_123"
onClose={() => {}}
accessToken="test-token"
is_org_admin={false}
is_proxy_admin={true}
userModels={[]}
editOrg={false}
/>,
);
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /Edit Settings/i }));
const alias = await screen.findByLabelText(/Organization Name/i);
await user.clear(alias);
await user.type(alias, "Renamed Org");
expect(alias).toHaveValue("Renamed Org");
await user.click(screen.getByRole("tab", { name: "Overview" }));
await user.click(screen.getByRole("tab", { name: "Settings" }));
expect(screen.getByLabelText(/Organization Name/i)).toHaveValue("Renamed Org");
});

View file

@ -1,14 +1,17 @@
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { organizationKeys, useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { organizationKeys, useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useVisitedTabs } from "@/hooks/useVisitedTabs";
import { MoneyCell } from "@/components/shared/table_cells"; import { MoneyCell } from "@/components/shared/table_cells";
import { formatNumberWithCommas, copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; import CopyButton from "@/components/shared/CopyButton";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { createTeamAliasMap } from "@/utils/teamUtils"; import { createTeamAliasMap } from "@/utils/teamUtils";
import { ArrowLeftIcon } from "@heroicons/react/outline";
import { Badge, Card, Grid, Text, Title, Button as TremorButton } from "@tremor/react";
import { Button, Tabs, Typography } from "antd";
import type { ColumnsType } from "antd/es/table"; import type { ColumnsType } from "antd/es/table";
import { CheckIcon, CopyIcon } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import MemberTable from "../common_components/MemberTable"; import MemberTable from "../common_components/MemberTable";
import UserSearchModal from "../common_components/user_search_modal"; import UserSearchModal from "../common_components/user_search_modal";
@ -48,9 +51,9 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false); const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false);
const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false); const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false);
const [selectedEditMember, setSelectedEditMember] = useState<Member | null>(null); const [selectedEditMember, setSelectedEditMember] = useState<Member | null>(null);
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
const canEditOrg = is_org_admin || is_proxy_admin; const canEditOrg = is_org_admin || is_proxy_admin;
const { data: teams } = useTeams(); const { data: teams } = useTeams();
const { onTabChange, hasVisited } = useVisitedTabs(editOrg ? "settings" : "overview");
const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]); const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]);
@ -118,16 +121,6 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
return <div className="p-4">Organization not found</div>; return <div className="p-4">Organization not found</div>;
} }
const copyToClipboard = async (text: string | null | undefined, key: string) => {
const success = await utilCopyToClipboard(text);
if (success) {
setCopiedStates((prev) => ({ ...prev, [key]: true }));
setTimeout(() => {
setCopiedStates((prev) => ({ ...prev, [key]: false }));
}, 2000);
}
};
const orgExtraColumns: ColumnsType<Member> = [ const orgExtraColumns: ColumnsType<Member> = [
{ {
title: "Spend (USD)", title: "Spend (USD)",
@ -144,108 +137,112 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
render: (_: unknown, record: Member) => { render: (_: unknown, record: Member) => {
const orgMember = const orgMember =
record.user_id != null ? (orgData.members || []).find((m) => m.user_id === record.user_id) : undefined; record.user_id != null ? (orgData.members || []).find((m) => m.user_id === record.user_id) : undefined;
return ( return <span>{orgMember?.created_at ? new Date(orgMember.created_at).toLocaleString() : "-"}</span>;
<Typography.Text>
{orgMember?.created_at ? new Date(orgMember.created_at).toLocaleString() : "-"}
</Typography.Text>
);
}, },
}, },
]; ];
return ( return (
<div className="w-full h-screen p-4 bg-white"> <div className="h-screen w-full bg-background p-4">
<div className="flex justify-between items-center mb-6"> <div className="mb-6 flex items-center justify-between">
<div> <div>
<TremorButton icon={ArrowLeftIcon} onClick={onClose} variant="light" className="mb-4"> <Button variant="ghost" onClick={onClose} className="mb-4">
<ArrowLeft className="size-4" />
Back to Organizations Back to Organizations
</TremorButton> </Button>
<Title>{orgData.organization_alias}</Title> <h1 className="text-xl font-semibold tracking-tight text-foreground">{orgData.organization_alias}</h1>
<div className="flex items-center cursor-pointer"> <div className="flex items-center gap-1">
<Text className="text-gray-500 font-mono">{orgData.organization_id}</Text> <span className="font-mono text-sm text-muted-foreground">{orgData.organization_id}</span>
<Button <CopyButton value={orgData.organization_id} label="Copy organization ID" iconClassName="size-3" />
type="text"
size="small"
icon={copiedStates["org-id"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
onClick={() => copyToClipboard(orgData.organization_id, "org-id")}
className={`left-2 z-10 transition-all duration-200 ${
copiedStates["org-id"]
? "text-green-600 bg-green-50 border-green-200"
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
}`}
/>
</div> </div>
</div> </div>
</div> </div>
<Tabs <Tabs defaultValue={editOrg ? "settings" : "overview"} onValueChange={onTabChange} className="mb-4">
defaultActiveKey={editOrg ? "settings" : "overview"} <TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
className="mb-4" <TabsTrigger value="overview" className="flex-none rounded-none px-4 py-2">
items={[ Overview
{ </TabsTrigger>
key: "overview", <TabsTrigger value="members" className="flex-none rounded-none px-4 py-2">
label: "Overview", Members
children: ( </TabsTrigger>
<Grid numItems={1} numItemsSm={2} numItemsLg={3} className="gap-6"> <TabsTrigger value="settings" className="flex-none rounded-none px-4 py-2">
Settings
</TabsTrigger>
</TabsList>
<TabsContent keepMounted={hasVisited("overview")} value="overview" className="pt-4">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<Card> <Card>
<Text>Organization Details</Text> <CardContent>
<div className="mt-2"> <p className="text-sm text-muted-foreground">Organization Details</p>
<Text>Created: {new Date(orgData.created_at).toLocaleDateString()}</Text> <div className="mt-2 text-sm text-foreground">
<Text>Updated: {new Date(orgData.updated_at).toLocaleDateString()}</Text> <p>Created: {new Date(orgData.created_at).toLocaleDateString()}</p>
<Text>Created By: {orgData.created_by}</Text> <p>Updated: {new Date(orgData.updated_at).toLocaleDateString()}</p>
<p>Created By: {orgData.created_by}</p>
</div> </div>
</CardContent>
</Card> </Card>
<Card> <Card>
<Text>Budget Status</Text> <CardContent>
<div className="mt-2"> <p className="text-sm text-muted-foreground">Budget Status</p>
<Title>${formatNumberWithCommas(orgData.spend, 4)}</Title> <div className="mt-2 text-sm text-foreground">
<Text> <p className="text-xl font-semibold">${formatNumberWithCommas(orgData.spend, 4)}</p>
<p>
of{" "} of{" "}
{orgData.litellm_budget_table.max_budget === null {orgData.litellm_budget_table.max_budget === null
? "Unlimited" ? "Unlimited"
: `$${formatNumberWithCommas(orgData.litellm_budget_table.max_budget, 4)}`} : `$${formatNumberWithCommas(orgData.litellm_budget_table.max_budget, 4)}`}
</Text> </p>
{orgData.litellm_budget_table.budget_duration && ( {orgData.litellm_budget_table.budget_duration && (
<Text className="text-gray-500">Reset: {orgData.litellm_budget_table.budget_duration}</Text> <p className="text-muted-foreground">Reset: {orgData.litellm_budget_table.budget_duration}</p>
)} )}
</div> </div>
</CardContent>
</Card> </Card>
<Card> <Card>
<Text>Rate Limits</Text> <CardContent>
<div className="mt-2"> <p className="text-sm text-muted-foreground">Rate Limits</p>
<Text>TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"}</Text> <div className="mt-2 text-sm text-foreground">
<Text>RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"}</Text> <p>TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"}</p>
<p>RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"}</p>
{orgData.litellm_budget_table.max_parallel_requests && ( {orgData.litellm_budget_table.max_parallel_requests && (
<Text>Max Parallel Requests: {orgData.litellm_budget_table.max_parallel_requests}</Text> <p>Max Parallel Requests: {orgData.litellm_budget_table.max_parallel_requests}</p>
)} )}
</div> </div>
</CardContent>
</Card> </Card>
<Card> <Card>
<Text>Models</Text> <CardContent>
<p className="text-sm text-muted-foreground">Models</p>
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{orgData.models.length === 0 ? ( {orgData.models.length === 0 ? (
<Badge color="red">All proxy models</Badge> <Badge variant="secondary">All proxy models</Badge>
) : ( ) : (
orgData.models.map((model, index) => ( orgData.models.map((model, index) => (
<Badge key={index} color="red"> <Badge key={index} variant="secondary">
{model} {model}
</Badge> </Badge>
)) ))
)} )}
</div> </div>
</CardContent>
</Card> </Card>
<Card> <Card>
<Text>Teams</Text> <CardContent>
<p className="text-sm text-muted-foreground">Teams</p>
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{orgData.teams?.map((team, index) => ( {orgData.teams?.map((team, index) => (
<Badge key={index} color="red"> <Badge key={index} variant="secondary">
{teamAliasMap[team.team_id] || team.team_id} {teamAliasMap[team.team_id] || team.team_id}
</Badge> </Badge>
))} ))}
</div> </div>
</CardContent>
</Card> </Card>
<ObjectPermissionsView <ObjectPermissionsView
@ -253,13 +250,10 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
variant="card" variant="card"
accessToken={accessToken} accessToken={accessToken}
/> />
</Grid> </div>
), </TabsContent>
},
{ <TabsContent keepMounted={hasVisited("members")} value="members" className="pt-4">
key: "members",
label: "Members",
children: (
<div className="space-y-4"> <div className="space-y-4">
<MemberTable <MemberTable
members={(orgData.members || []).map((m) => ({ members={(orgData.members || []).map((m) => ({
@ -279,18 +273,14 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
emptyText="No members found" emptyText="No members found"
/> />
</div> </div>
), </TabsContent>
},
{ <TabsContent keepMounted={hasVisited("settings")} value="settings" className="pt-4">
key: "settings", <Card className="max-h-[65vh] overflow-y-auto">
label: "Settings", <CardContent>
children: ( <div className="mb-4 flex items-center justify-between">
<Card className="overflow-y-auto max-h-[65vh]"> <h2 className="text-lg font-semibold text-foreground">Organization Settings</h2>
<div className="flex justify-between items-center mb-4"> {canEditOrg && !isEditing && <Button onClick={() => setIsEditing(true)}>Edit Settings</Button>}
<Title>Organization Settings</Title>
{canEditOrg && !isEditing && (
<TremorButton onClick={() => setIsEditing(true)}>Edit Settings</TremorButton>
)}
</div> </div>
{isEditing ? ( {isEditing ? (
@ -302,36 +292,36 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
onSaved={() => setIsEditing(false)} onSaved={() => setIsEditing(false)}
/> />
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4 text-sm">
<div> <div>
<Text className="font-medium">Organization Name</Text> <p className="font-medium text-foreground">Organization Name</p>
<div>{orgData.organization_alias}</div> <div>{orgData.organization_alias}</div>
</div> </div>
<div> <div>
<Text className="font-medium">Organization ID</Text> <p className="font-medium text-foreground">Organization ID</p>
<div className="font-mono">{orgData.organization_id}</div> <div className="font-mono">{orgData.organization_id}</div>
</div> </div>
<div> <div>
<Text className="font-medium">Created At</Text> <p className="font-medium text-foreground">Created At</p>
<div>{new Date(orgData.created_at).toLocaleString()}</div> <div>{new Date(orgData.created_at).toLocaleString()}</div>
</div> </div>
<div> <div>
<Text className="font-medium">Models</Text> <p className="font-medium text-foreground">Models</p>
<div className="flex flex-wrap gap-2 mt-1"> <div className="mt-1 flex flex-wrap gap-2">
{orgData.models.map((model, index) => ( {orgData.models.map((model, index) => (
<Badge key={index} color="red"> <Badge key={index} variant="secondary">
{model} {model}
</Badge> </Badge>
))} ))}
</div> </div>
</div> </div>
<div> <div>
<Text className="font-medium">Rate Limits</Text> <p className="font-medium text-foreground">Rate Limits</p>
<div>TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"}</div> <div>TPM: {orgData.litellm_budget_table.tpm_limit || "Unlimited"}</div>
<div>RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"}</div> <div>RPM: {orgData.litellm_budget_table.rpm_limit || "Unlimited"}</div>
</div> </div>
<div> <div>
<Text className="font-medium">Budget</Text> <p className="font-medium text-foreground">Budget</p>
<div> <div>
Max:{" "} Max:{" "}
{orgData.litellm_budget_table.max_budget !== null {orgData.litellm_budget_table.max_budget !== null
@ -344,16 +334,16 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
<ObjectPermissionsView <ObjectPermissionsView
objectPermission={orgData.object_permission} objectPermission={orgData.object_permission}
variant="inline" variant="inline"
className="pt-4 border-t border-gray-200" className="border-t pt-4"
accessToken={accessToken} accessToken={accessToken}
/> />
</div> </div>
)} )}
</CardContent>
</Card> </Card>
), </TabsContent>
}, </Tabs>
]}
/>
<UserSearchModal <UserSearchModal
isVisible={isAddMemberModalVisible} isVisible={isAddMemberModalVisible}
onCancel={() => setIsAddMemberModalVisible(false)} onCancel={() => setIsAddMemberModalVisible(false)}

Some files were not shown because too many files have changed in this diff Show more