atlas-chat 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- atlas/__init__.py +40 -0
- atlas/application/__init__.py +7 -0
- atlas/application/chat/__init__.py +7 -0
- atlas/application/chat/agent/__init__.py +10 -0
- atlas/application/chat/agent/act_loop.py +179 -0
- atlas/application/chat/agent/factory.py +142 -0
- atlas/application/chat/agent/protocols.py +46 -0
- atlas/application/chat/agent/react_loop.py +338 -0
- atlas/application/chat/agent/think_act_loop.py +171 -0
- atlas/application/chat/approval_manager.py +151 -0
- atlas/application/chat/elicitation_manager.py +191 -0
- atlas/application/chat/events/__init__.py +1 -0
- atlas/application/chat/events/agent_event_relay.py +112 -0
- atlas/application/chat/modes/__init__.py +1 -0
- atlas/application/chat/modes/agent.py +125 -0
- atlas/application/chat/modes/plain.py +74 -0
- atlas/application/chat/modes/rag.py +81 -0
- atlas/application/chat/modes/tools.py +179 -0
- atlas/application/chat/orchestrator.py +213 -0
- atlas/application/chat/policies/__init__.py +1 -0
- atlas/application/chat/policies/tool_authorization.py +99 -0
- atlas/application/chat/preprocessors/__init__.py +1 -0
- atlas/application/chat/preprocessors/message_builder.py +92 -0
- atlas/application/chat/preprocessors/prompt_override_service.py +104 -0
- atlas/application/chat/service.py +454 -0
- atlas/application/chat/utilities/__init__.py +6 -0
- atlas/application/chat/utilities/error_handler.py +367 -0
- atlas/application/chat/utilities/event_notifier.py +546 -0
- atlas/application/chat/utilities/file_processor.py +613 -0
- atlas/application/chat/utilities/tool_executor.py +789 -0
- atlas/atlas_chat_cli.py +347 -0
- atlas/atlas_client.py +238 -0
- atlas/core/__init__.py +0 -0
- atlas/core/auth.py +205 -0
- atlas/core/authorization_manager.py +27 -0
- atlas/core/capabilities.py +123 -0
- atlas/core/compliance.py +215 -0
- atlas/core/domain_whitelist.py +147 -0
- atlas/core/domain_whitelist_middleware.py +82 -0
- atlas/core/http_client.py +28 -0
- atlas/core/log_sanitizer.py +102 -0
- atlas/core/metrics_logger.py +59 -0
- atlas/core/middleware.py +131 -0
- atlas/core/otel_config.py +242 -0
- atlas/core/prompt_risk.py +200 -0
- atlas/core/rate_limit.py +0 -0
- atlas/core/rate_limit_middleware.py +64 -0
- atlas/core/security_headers_middleware.py +51 -0
- atlas/domain/__init__.py +37 -0
- atlas/domain/chat/__init__.py +1 -0
- atlas/domain/chat/dtos.py +85 -0
- atlas/domain/errors.py +96 -0
- atlas/domain/messages/__init__.py +12 -0
- atlas/domain/messages/models.py +160 -0
- atlas/domain/rag_mcp_service.py +664 -0
- atlas/domain/sessions/__init__.py +7 -0
- atlas/domain/sessions/models.py +36 -0
- atlas/domain/unified_rag_service.py +371 -0
- atlas/infrastructure/__init__.py +10 -0
- atlas/infrastructure/app_factory.py +135 -0
- atlas/infrastructure/events/__init__.py +1 -0
- atlas/infrastructure/events/cli_event_publisher.py +140 -0
- atlas/infrastructure/events/websocket_publisher.py +140 -0
- atlas/infrastructure/sessions/in_memory_repository.py +56 -0
- atlas/infrastructure/transport/__init__.py +7 -0
- atlas/infrastructure/transport/websocket_connection_adapter.py +33 -0
- atlas/init_cli.py +226 -0
- atlas/interfaces/__init__.py +15 -0
- atlas/interfaces/events.py +134 -0
- atlas/interfaces/llm.py +54 -0
- atlas/interfaces/rag.py +40 -0
- atlas/interfaces/sessions.py +75 -0
- atlas/interfaces/tools.py +57 -0
- atlas/interfaces/transport.py +24 -0
- atlas/main.py +564 -0
- atlas/mcp/api_key_demo/README.md +76 -0
- atlas/mcp/api_key_demo/main.py +172 -0
- atlas/mcp/api_key_demo/run.sh +56 -0
- atlas/mcp/basictable/main.py +147 -0
- atlas/mcp/calculator/main.py +149 -0
- atlas/mcp/code-executor/execution_engine.py +98 -0
- atlas/mcp/code-executor/execution_environment.py +95 -0
- atlas/mcp/code-executor/main.py +528 -0
- atlas/mcp/code-executor/result_processing.py +276 -0
- atlas/mcp/code-executor/script_generation.py +195 -0
- atlas/mcp/code-executor/security_checker.py +140 -0
- atlas/mcp/corporate_cars/main.py +437 -0
- atlas/mcp/csv_reporter/main.py +545 -0
- atlas/mcp/duckduckgo/main.py +182 -0
- atlas/mcp/elicitation_demo/README.md +171 -0
- atlas/mcp/elicitation_demo/main.py +262 -0
- atlas/mcp/env-demo/README.md +158 -0
- atlas/mcp/env-demo/main.py +199 -0
- atlas/mcp/file_size_test/main.py +284 -0
- atlas/mcp/filesystem/main.py +348 -0
- atlas/mcp/image_demo/main.py +113 -0
- atlas/mcp/image_demo/requirements.txt +4 -0
- atlas/mcp/logging_demo/README.md +72 -0
- atlas/mcp/logging_demo/main.py +103 -0
- atlas/mcp/many_tools_demo/main.py +50 -0
- atlas/mcp/order_database/__init__.py +0 -0
- atlas/mcp/order_database/main.py +369 -0
- atlas/mcp/order_database/signal_data.csv +1001 -0
- atlas/mcp/pdfbasic/main.py +394 -0
- atlas/mcp/pptx_generator/main.py +760 -0
- atlas/mcp/pptx_generator/requirements.txt +13 -0
- atlas/mcp/pptx_generator/run_test.sh +1 -0
- atlas/mcp/pptx_generator/test_pptx_generator_security.py +169 -0
- atlas/mcp/progress_demo/main.py +167 -0
- atlas/mcp/progress_updates_demo/QUICKSTART.md +273 -0
- atlas/mcp/progress_updates_demo/README.md +120 -0
- atlas/mcp/progress_updates_demo/main.py +497 -0
- atlas/mcp/prompts/main.py +222 -0
- atlas/mcp/public_demo/main.py +189 -0
- atlas/mcp/sampling_demo/README.md +169 -0
- atlas/mcp/sampling_demo/main.py +234 -0
- atlas/mcp/thinking/main.py +77 -0
- atlas/mcp/tool_planner/main.py +240 -0
- atlas/mcp/ui-demo/badmesh.png +0 -0
- atlas/mcp/ui-demo/main.py +383 -0
- atlas/mcp/ui-demo/templates/button_demo.html +32 -0
- atlas/mcp/ui-demo/templates/data_visualization.html +32 -0
- atlas/mcp/ui-demo/templates/form_demo.html +28 -0
- atlas/mcp/username-override-demo/README.md +320 -0
- atlas/mcp/username-override-demo/main.py +308 -0
- atlas/modules/__init__.py +0 -0
- atlas/modules/config/__init__.py +34 -0
- atlas/modules/config/cli.py +231 -0
- atlas/modules/config/config_manager.py +1096 -0
- atlas/modules/file_storage/__init__.py +22 -0
- atlas/modules/file_storage/cli.py +330 -0
- atlas/modules/file_storage/content_extractor.py +290 -0
- atlas/modules/file_storage/manager.py +295 -0
- atlas/modules/file_storage/mock_s3_client.py +402 -0
- atlas/modules/file_storage/s3_client.py +417 -0
- atlas/modules/llm/__init__.py +19 -0
- atlas/modules/llm/caller.py +287 -0
- atlas/modules/llm/litellm_caller.py +675 -0
- atlas/modules/llm/models.py +19 -0
- atlas/modules/mcp_tools/__init__.py +17 -0
- atlas/modules/mcp_tools/client.py +2123 -0
- atlas/modules/mcp_tools/token_storage.py +556 -0
- atlas/modules/prompts/prompt_provider.py +130 -0
- atlas/modules/rag/__init__.py +24 -0
- atlas/modules/rag/atlas_rag_client.py +336 -0
- atlas/modules/rag/client.py +129 -0
- atlas/routes/admin_routes.py +865 -0
- atlas/routes/config_routes.py +484 -0
- atlas/routes/feedback_routes.py +361 -0
- atlas/routes/files_routes.py +274 -0
- atlas/routes/health_routes.py +40 -0
- atlas/routes/mcp_auth_routes.py +223 -0
- atlas/server_cli.py +164 -0
- atlas/tests/conftest.py +20 -0
- atlas/tests/integration/test_mcp_auth_integration.py +152 -0
- atlas/tests/manual_test_sampling.py +87 -0
- atlas/tests/modules/mcp_tools/test_client_auth.py +226 -0
- atlas/tests/modules/mcp_tools/test_client_env.py +191 -0
- atlas/tests/test_admin_mcp_server_management_routes.py +141 -0
- atlas/tests/test_agent_roa.py +135 -0
- atlas/tests/test_app_factory_smoke.py +47 -0
- atlas/tests/test_approval_manager.py +439 -0
- atlas/tests/test_atlas_client.py +188 -0
- atlas/tests/test_atlas_rag_client.py +447 -0
- atlas/tests/test_atlas_rag_integration.py +224 -0
- atlas/tests/test_attach_file_flow.py +287 -0
- atlas/tests/test_auth_utils.py +165 -0
- atlas/tests/test_backend_public_url.py +185 -0
- atlas/tests/test_banner_logging.py +287 -0
- atlas/tests/test_capability_tokens_and_injection.py +203 -0
- atlas/tests/test_compliance_level.py +54 -0
- atlas/tests/test_compliance_manager.py +253 -0
- atlas/tests/test_config_manager.py +617 -0
- atlas/tests/test_config_manager_paths.py +12 -0
- atlas/tests/test_core_auth.py +18 -0
- atlas/tests/test_core_utils.py +190 -0
- atlas/tests/test_docker_env_sync.py +202 -0
- atlas/tests/test_domain_errors.py +329 -0
- atlas/tests/test_domain_whitelist.py +359 -0
- atlas/tests/test_elicitation_manager.py +408 -0
- atlas/tests/test_elicitation_routing.py +296 -0
- atlas/tests/test_env_demo_server.py +88 -0
- atlas/tests/test_error_classification.py +113 -0
- atlas/tests/test_error_flow_integration.py +116 -0
- atlas/tests/test_feedback_routes.py +333 -0
- atlas/tests/test_file_content_extraction.py +1134 -0
- atlas/tests/test_file_extraction_routes.py +158 -0
- atlas/tests/test_file_library.py +107 -0
- atlas/tests/test_file_manager_unit.py +18 -0
- atlas/tests/test_health_route.py +49 -0
- atlas/tests/test_http_client_stub.py +8 -0
- atlas/tests/test_imports_smoke.py +30 -0
- atlas/tests/test_interfaces_llm_response.py +9 -0
- atlas/tests/test_issue_access_denied_fix.py +136 -0
- atlas/tests/test_llm_env_expansion.py +836 -0
- atlas/tests/test_log_level_sensitive_data.py +285 -0
- atlas/tests/test_mcp_auth_routes.py +341 -0
- atlas/tests/test_mcp_client_auth.py +331 -0
- atlas/tests/test_mcp_data_injection.py +270 -0
- atlas/tests/test_mcp_get_authorized_servers.py +95 -0
- atlas/tests/test_mcp_hot_reload.py +512 -0
- atlas/tests/test_mcp_image_content.py +424 -0
- atlas/tests/test_mcp_logging.py +172 -0
- atlas/tests/test_mcp_progress_updates.py +313 -0
- atlas/tests/test_mcp_prompt_override_system_prompt.py +102 -0
- atlas/tests/test_mcp_prompts_server.py +39 -0
- atlas/tests/test_mcp_tool_result_parsing.py +296 -0
- atlas/tests/test_metrics_logger.py +56 -0
- atlas/tests/test_middleware_auth.py +379 -0
- atlas/tests/test_prompt_risk_and_acl.py +141 -0
- atlas/tests/test_rag_mcp_aggregator.py +204 -0
- atlas/tests/test_rag_mcp_service.py +224 -0
- atlas/tests/test_rate_limit_middleware.py +45 -0
- atlas/tests/test_routes_config_smoke.py +60 -0
- atlas/tests/test_routes_files_download_token.py +41 -0
- atlas/tests/test_routes_files_health.py +18 -0
- atlas/tests/test_runtime_imports.py +53 -0
- atlas/tests/test_sampling_integration.py +482 -0
- atlas/tests/test_security_admin_routes.py +61 -0
- atlas/tests/test_security_capability_tokens.py +65 -0
- atlas/tests/test_security_file_stats_scope.py +21 -0
- atlas/tests/test_security_header_injection.py +191 -0
- atlas/tests/test_security_headers_and_filename.py +63 -0
- atlas/tests/test_shared_session_repository.py +101 -0
- atlas/tests/test_system_prompt_loading.py +181 -0
- atlas/tests/test_token_storage.py +505 -0
- atlas/tests/test_tool_approval_config.py +93 -0
- atlas/tests/test_tool_approval_utils.py +356 -0
- atlas/tests/test_tool_authorization_group_filtering.py +223 -0
- atlas/tests/test_tool_details_in_config.py +108 -0
- atlas/tests/test_tool_planner.py +300 -0
- atlas/tests/test_unified_rag_service.py +398 -0
- atlas/tests/test_username_override_in_approval.py +258 -0
- atlas/tests/test_websocket_auth_header.py +168 -0
- atlas/version.py +6 -0
- atlas_chat-0.1.0.data/data/.env.example +253 -0
- atlas_chat-0.1.0.data/data/config/defaults/compliance-levels.json +44 -0
- atlas_chat-0.1.0.data/data/config/defaults/domain-whitelist.json +123 -0
- atlas_chat-0.1.0.data/data/config/defaults/file-extractors.json +74 -0
- atlas_chat-0.1.0.data/data/config/defaults/help-config.json +198 -0
- atlas_chat-0.1.0.data/data/config/defaults/llmconfig-buggy.yml +11 -0
- atlas_chat-0.1.0.data/data/config/defaults/llmconfig.yml +19 -0
- atlas_chat-0.1.0.data/data/config/defaults/mcp.json +138 -0
- atlas_chat-0.1.0.data/data/config/defaults/rag-sources.json +17 -0
- atlas_chat-0.1.0.data/data/config/defaults/splash-config.json +16 -0
- atlas_chat-0.1.0.dist-info/METADATA +236 -0
- atlas_chat-0.1.0.dist-info/RECORD +250 -0
- atlas_chat-0.1.0.dist-info/WHEEL +5 -0
- atlas_chat-0.1.0.dist-info/entry_points.txt +4 -0
- atlas_chat-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
"""Admin routes for configuration management and system monitoring.
|
|
2
|
+
|
|
3
|
+
Provides admin-only endpoints for: banners, configuration files, logs, and (commented) health checks.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
16
|
+
from fastapi.responses import FileResponse
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
|
|
19
|
+
from atlas.core.auth import is_user_in_group
|
|
20
|
+
from atlas.core.log_sanitizer import get_current_user, sanitize_for_logging
|
|
21
|
+
from atlas.infrastructure.app_factory import app_factory
|
|
22
|
+
from atlas.modules.config import config_manager
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
admin_router = APIRouter(prefix="/admin", tags=["admin"])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AdminConfigUpdate(BaseModel):
|
|
30
|
+
content: str
|
|
31
|
+
file_type: str # 'json', 'yaml', 'text'
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BannerMessageUpdate(BaseModel):
|
|
35
|
+
messages: List[str]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MCPServerAction(BaseModel):
|
|
39
|
+
server_name: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def require_admin(current_user: str = Depends(get_current_user)) -> str:
|
|
43
|
+
admin_group = config_manager.app_settings.admin_group
|
|
44
|
+
if not await is_user_in_group(current_user, admin_group):
|
|
45
|
+
raise HTTPException(
|
|
46
|
+
status_code=403,
|
|
47
|
+
detail=f"Admin access required. User must be in '{admin_group}' group.",
|
|
48
|
+
)
|
|
49
|
+
return current_user
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def setup_config_overrides() -> None:
|
|
53
|
+
"""Ensure editable overrides directory exists; seed from defaults / legacy if empty."""
|
|
54
|
+
app_settings = config_manager.app_settings
|
|
55
|
+
overrides_root = Path(app_settings.app_config_overrides)
|
|
56
|
+
defaults_root = Path(app_settings.app_config_defaults)
|
|
57
|
+
|
|
58
|
+
# If relative paths, resolve from project root
|
|
59
|
+
if not overrides_root.is_absolute():
|
|
60
|
+
project_root = Path(__file__).parent.parent.parent
|
|
61
|
+
overrides_root = project_root / overrides_root
|
|
62
|
+
if not defaults_root.is_absolute():
|
|
63
|
+
project_root = Path(__file__).parent.parent.parent
|
|
64
|
+
defaults_root = project_root / defaults_root
|
|
65
|
+
|
|
66
|
+
overrides_root.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
defaults_root.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
|
|
69
|
+
if any(overrides_root.iterdir()):
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
logger.info("Seeding empty overrides directory")
|
|
73
|
+
seed_sources = [
|
|
74
|
+
defaults_root,
|
|
75
|
+
Path("backend/configfilesadmin"),
|
|
76
|
+
Path("backend/configfiles"),
|
|
77
|
+
Path("configfilesadmin"),
|
|
78
|
+
Path("configfiles"),
|
|
79
|
+
]
|
|
80
|
+
for source in seed_sources:
|
|
81
|
+
if source.exists() and any(source.iterdir()):
|
|
82
|
+
for file_path in source.glob("*"):
|
|
83
|
+
if file_path.is_file():
|
|
84
|
+
dest = overrides_root / file_path.name
|
|
85
|
+
try:
|
|
86
|
+
shutil.copy2(file_path, dest)
|
|
87
|
+
logger.info(f"Copied seed config {sanitize_for_logging(str(file_path))} -> {sanitize_for_logging(str(dest))}")
|
|
88
|
+
except Exception as e: # noqa: BLE001
|
|
89
|
+
logger.error(f"Failed seeding {sanitize_for_logging(str(file_path))}: {e}")
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_admin_config_path(filename: str) -> Path:
|
|
94
|
+
# Get config filename mappings from config manager
|
|
95
|
+
app_settings = config_manager.app_settings
|
|
96
|
+
|
|
97
|
+
# Map standard filenames to potentially overridden ones
|
|
98
|
+
if filename == "messages.txt":
|
|
99
|
+
custom_filename = app_settings.messages_config_file
|
|
100
|
+
elif filename == "help-config.json":
|
|
101
|
+
custom_filename = app_settings.help_config_file
|
|
102
|
+
elif filename == "mcp.json":
|
|
103
|
+
custom_filename = app_settings.mcp_config_file
|
|
104
|
+
elif filename == "llmconfig.yml":
|
|
105
|
+
custom_filename = app_settings.llm_config_file
|
|
106
|
+
else:
|
|
107
|
+
custom_filename = filename
|
|
108
|
+
|
|
109
|
+
# Use same logic as config manager to resolve relative paths from project root
|
|
110
|
+
base = Path(app_settings.app_config_overrides)
|
|
111
|
+
|
|
112
|
+
# If relative path, resolve from project root (parent of backend directory)
|
|
113
|
+
if not base.is_absolute():
|
|
114
|
+
project_root = Path(__file__).parent.parent.parent # Go up from atlas.routes/ to backend/ to project root
|
|
115
|
+
base = project_root / base
|
|
116
|
+
|
|
117
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
return base / custom_filename
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_file_content(file_path: Path) -> str:
|
|
122
|
+
if not file_path.exists():
|
|
123
|
+
raise HTTPException(status_code=404, detail=f"File {file_path.name} not found")
|
|
124
|
+
try:
|
|
125
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
126
|
+
return f.read()
|
|
127
|
+
except UnicodeDecodeError:
|
|
128
|
+
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
129
|
+
return f.read()
|
|
130
|
+
except Exception as e: # noqa: BLE001
|
|
131
|
+
logger.error(f"Error reading file {file_path}: {e}")
|
|
132
|
+
raise HTTPException(status_code=500, detail=f"Error reading file: {e}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def write_file_content(file_path: Path, content: str, file_type: str = "text") -> None:
|
|
136
|
+
try:
|
|
137
|
+
if file_type == "json":
|
|
138
|
+
json.loads(content)
|
|
139
|
+
elif file_type == "yaml":
|
|
140
|
+
yaml.safe_load(content)
|
|
141
|
+
|
|
142
|
+
temp_path = file_path.with_suffix(file_path.suffix + ".tmp")
|
|
143
|
+
if temp_path.exists():
|
|
144
|
+
temp_path.unlink()
|
|
145
|
+
with open(temp_path, "w", encoding="utf-8") as f:
|
|
146
|
+
f.write(content)
|
|
147
|
+
if os.name == "nt" and file_path.exists(): # Windows atomic rename safety
|
|
148
|
+
file_path.unlink()
|
|
149
|
+
temp_path.rename(file_path)
|
|
150
|
+
logger.info(f"Updated config file {file_path}")
|
|
151
|
+
except (json.JSONDecodeError, yaml.YAMLError) as e:
|
|
152
|
+
raise HTTPException(status_code=400, detail=f"Invalid {file_type.upper()}: {e}")
|
|
153
|
+
except Exception as e: # noqa: BLE001
|
|
154
|
+
logger.error(f"Error writing file {file_path}: {e}")
|
|
155
|
+
raise HTTPException(status_code=500, detail=f"Error writing file: {e}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _project_root() -> Path:
|
|
159
|
+
# routes/admin_routes.py -> backend/routes -> project root is 2 levels up
|
|
160
|
+
return Path(__file__).resolve().parents[2]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _log_base_dir() -> Path:
|
|
164
|
+
app_settings = config_manager.app_settings
|
|
165
|
+
if app_settings.app_log_dir:
|
|
166
|
+
return Path(app_settings.app_log_dir)
|
|
167
|
+
return _project_root() / "logs"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _locate_log_file() -> Path:
|
|
171
|
+
"""Locate the log file (standardized on project_root/logs with optional override).
|
|
172
|
+
|
|
173
|
+
Priority:
|
|
174
|
+
1. APP_LOG_DIR (env) if set
|
|
175
|
+
2. ./logs
|
|
176
|
+
3. Legacy fallbacks (backend/logs, runtime/logs) for backward compatibility
|
|
177
|
+
"""
|
|
178
|
+
base = _log_base_dir()
|
|
179
|
+
candidates = [
|
|
180
|
+
base / "app.jsonl",
|
|
181
|
+
base / "app.log",
|
|
182
|
+
Path("logs/app.jsonl"),
|
|
183
|
+
Path("logs/app.log"),
|
|
184
|
+
Path("backend/logs/app.jsonl"), # legacy
|
|
185
|
+
Path("backend/logs/app.log"), # legacy
|
|
186
|
+
Path("runtime/logs/app.jsonl"), # legacy
|
|
187
|
+
Path("runtime/logs/app.log"), # legacy
|
|
188
|
+
]
|
|
189
|
+
for c in candidates:
|
|
190
|
+
if c.exists():
|
|
191
|
+
return c
|
|
192
|
+
raise HTTPException(status_code=404, detail="Log file not found")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@admin_router.get("/")
|
|
196
|
+
async def admin_dashboard(admin_user: str = Depends(require_admin)):
|
|
197
|
+
return {
|
|
198
|
+
"message": "Admin Dashboard",
|
|
199
|
+
"user": admin_user,
|
|
200
|
+
"available_endpoints": [
|
|
201
|
+
"/admin/banners",
|
|
202
|
+
"/admin/logs/viewer",
|
|
203
|
+
"/admin/logs/clear",
|
|
204
|
+
"/admin/logs/download",
|
|
205
|
+
"/admin/mcp/reload",
|
|
206
|
+
"/admin/mcp/reconnect",
|
|
207
|
+
"/admin/mcp/status",
|
|
208
|
+
],
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@admin_router.get("/banners")
|
|
213
|
+
async def get_banner_config(admin_user: str = Depends(require_admin)):
|
|
214
|
+
try:
|
|
215
|
+
setup_config_overrides()
|
|
216
|
+
messages_file = get_admin_config_path("messages.txt")
|
|
217
|
+
if not messages_file.exists():
|
|
218
|
+
write_file_content(messages_file, "System status: All services operational\n")
|
|
219
|
+
content = get_file_content(messages_file)
|
|
220
|
+
messages = [ln.strip() for ln in content.splitlines() if ln.strip()]
|
|
221
|
+
return {
|
|
222
|
+
"messages": messages,
|
|
223
|
+
"file_path": str(messages_file),
|
|
224
|
+
"last_modified": messages_file.stat().st_mtime,
|
|
225
|
+
"banner_enabled": config_manager.app_settings.banner_enabled,
|
|
226
|
+
}
|
|
227
|
+
except Exception as e: # noqa: BLE001
|
|
228
|
+
logger.error(f"Error getting banner config: {e}")
|
|
229
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@admin_router.post("/banners")
|
|
233
|
+
async def update_banner_config(
|
|
234
|
+
update: BannerMessageUpdate, admin_user: str = Depends(require_admin)
|
|
235
|
+
):
|
|
236
|
+
messages_file = None
|
|
237
|
+
try:
|
|
238
|
+
setup_config_overrides()
|
|
239
|
+
messages_file = get_admin_config_path("messages.txt")
|
|
240
|
+
content = ("\n".join(update.messages) + "\n") if update.messages else ""
|
|
241
|
+
write_file_content(messages_file, content)
|
|
242
|
+
logger.info(
|
|
243
|
+
f"Banner messages successfully saved to disk at {sanitize_for_logging(str(messages_file))} "
|
|
244
|
+
f"by {sanitize_for_logging(admin_user)}"
|
|
245
|
+
)
|
|
246
|
+
return {
|
|
247
|
+
"message": "Banner messages updated successfully",
|
|
248
|
+
"messages": update.messages,
|
|
249
|
+
"updated_by": admin_user,
|
|
250
|
+
}
|
|
251
|
+
except Exception as e: # noqa: BLE001
|
|
252
|
+
file_path_str = sanitize_for_logging(str(messages_file)) if messages_file else "unknown path"
|
|
253
|
+
logger.error(
|
|
254
|
+
f"Failed to save banner messages to disk at {file_path_str}: {e}"
|
|
255
|
+
)
|
|
256
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@admin_router.post("/mcp/reload")
|
|
260
|
+
async def reload_mcp_servers(admin_user: str = Depends(require_admin)):
|
|
261
|
+
"""Reload MCP servers from disk configuration and reinitialize connections.
|
|
262
|
+
|
|
263
|
+
This endpoint:
|
|
264
|
+
1. Reloads the mcp.json configuration from disk (hot-reload)
|
|
265
|
+
2. Reinitializes all MCP client connections
|
|
266
|
+
3. Rediscovers tools and prompts from all servers
|
|
267
|
+
|
|
268
|
+
Use this after modifying the mcp.json configuration file to apply changes
|
|
269
|
+
without restarting the application.
|
|
270
|
+
"""
|
|
271
|
+
try:
|
|
272
|
+
mcp = app_factory.get_mcp_manager()
|
|
273
|
+
|
|
274
|
+
# Reload config from disk first
|
|
275
|
+
config_changes = mcp.reload_config()
|
|
276
|
+
|
|
277
|
+
# Re-initialize clients and rediscover
|
|
278
|
+
await mcp.initialize_clients()
|
|
279
|
+
await mcp.discover_tools()
|
|
280
|
+
await mcp.discover_prompts()
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
"message": "MCP servers reloaded from disk configuration",
|
|
284
|
+
"config_changes": config_changes,
|
|
285
|
+
"servers": list(mcp.clients.keys()),
|
|
286
|
+
"failed_servers": list(mcp.get_failed_servers().keys()),
|
|
287
|
+
"tool_counts": {k: len(v.get("tools", [])) for k, v in mcp.available_tools.items()},
|
|
288
|
+
"prompt_counts": {k: len(v.get("prompts", [])) for k, v in mcp.available_prompts.items()},
|
|
289
|
+
"reloaded_by": admin_user,
|
|
290
|
+
}
|
|
291
|
+
except Exception as e: # noqa: BLE001
|
|
292
|
+
logger.error(f"Error reloading MCP servers: {e}", exc_info=True)
|
|
293
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@admin_router.post("/mcp/reconnect")
|
|
297
|
+
async def reconnect_failed_mcp_servers(admin_user: str = Depends(require_admin)):
|
|
298
|
+
"""Attempt to reconnect to MCP servers that previously failed.
|
|
299
|
+
|
|
300
|
+
This endpoint manually triggers reconnection attempts for servers that failed
|
|
301
|
+
to connect during initialization or previous reconnection attempts.
|
|
302
|
+
Respects exponential backoff unless force=true is specified.
|
|
303
|
+
"""
|
|
304
|
+
try:
|
|
305
|
+
mcp = app_factory.get_mcp_manager()
|
|
306
|
+
# Admin-triggered reconnect should bypass backoff and try immediately
|
|
307
|
+
result = await mcp.reconnect_failed_servers(force=True)
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
"message": "Reconnection attempt completed",
|
|
311
|
+
"result": result,
|
|
312
|
+
"current_servers": list(mcp.clients.keys()),
|
|
313
|
+
"failed_servers": mcp.get_failed_servers(),
|
|
314
|
+
"triggered_by": admin_user,
|
|
315
|
+
}
|
|
316
|
+
except Exception as e: # noqa: BLE001
|
|
317
|
+
logger.error(f"Error reconnecting MCP servers: {e}", exc_info=True)
|
|
318
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@admin_router.get("/mcp/status")
|
|
322
|
+
async def get_mcp_status(admin_user: str = Depends(require_admin)):
|
|
323
|
+
"""Get current MCP server connection status.
|
|
324
|
+
|
|
325
|
+
Returns information about:
|
|
326
|
+
- Currently connected servers
|
|
327
|
+
- Failed servers with error details and backoff info
|
|
328
|
+
- Auto-reconnect feature status
|
|
329
|
+
"""
|
|
330
|
+
try:
|
|
331
|
+
mcp = app_factory.get_mcp_manager()
|
|
332
|
+
app_settings = config_manager.app_settings
|
|
333
|
+
|
|
334
|
+
failed_servers = mcp.get_failed_servers()
|
|
335
|
+
|
|
336
|
+
# Calculate next retry time for each failed server
|
|
337
|
+
current_time = time.time()
|
|
338
|
+
failed_servers_with_timing = {}
|
|
339
|
+
for server_name, failure_info in failed_servers.items():
|
|
340
|
+
backoff_delay = mcp._calculate_backoff_delay(failure_info["attempt_count"])
|
|
341
|
+
time_since_last = current_time - failure_info["last_attempt"]
|
|
342
|
+
next_retry_in = max(0, backoff_delay - time_since_last)
|
|
343
|
+
|
|
344
|
+
failed_servers_with_timing[server_name] = {
|
|
345
|
+
**failure_info,
|
|
346
|
+
"backoff_delay": backoff_delay,
|
|
347
|
+
"next_retry_in_seconds": next_retry_in,
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
# A server is considered "connected" only if it has a client AND
|
|
351
|
+
# at least one tool or prompt discovered (or explicitly marked as
|
|
352
|
+
# having zero tools/prompts but no recorded failure). This prevents
|
|
353
|
+
# HTTP/SSE/SSL discovery failures from showing as connected.
|
|
354
|
+
connected_servers: List[str] = []
|
|
355
|
+
for server_name in mcp.clients.keys():
|
|
356
|
+
tools = mcp.available_tools.get(server_name, {}).get("tools", [])
|
|
357
|
+
prompts = mcp.available_prompts.get(server_name, {}).get("prompts", [])
|
|
358
|
+
if tools or prompts:
|
|
359
|
+
connected_servers.append(server_name)
|
|
360
|
+
elif server_name not in failed_servers_with_timing:
|
|
361
|
+
# No tools/prompts but also no recorded failure; treat as connected
|
|
362
|
+
# to preserve behavior for servers that legitimately expose nothing.
|
|
363
|
+
connected_servers.append(server_name)
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
"connected_servers": connected_servers,
|
|
367
|
+
"configured_servers": list(mcp.servers_config.keys()),
|
|
368
|
+
"failed_servers": failed_servers_with_timing,
|
|
369
|
+
"auto_reconnect": {
|
|
370
|
+
"enabled": app_settings.feature_mcp_auto_reconnect_enabled,
|
|
371
|
+
"base_interval": app_settings.mcp_reconnect_interval,
|
|
372
|
+
"max_interval": app_settings.mcp_reconnect_max_interval,
|
|
373
|
+
"backoff_multiplier": app_settings.mcp_reconnect_backoff_multiplier,
|
|
374
|
+
"running": mcp._reconnect_running,
|
|
375
|
+
},
|
|
376
|
+
"tool_counts": {k: len(v.get("tools", [])) for k, v in mcp.available_tools.items()},
|
|
377
|
+
"prompt_counts": {k: len(v.get("prompts", [])) for k, v in mcp.available_prompts.items()},
|
|
378
|
+
}
|
|
379
|
+
except Exception as e: # noqa: BLE001
|
|
380
|
+
logger.error(f"Error getting MCP status: {e}", exc_info=True)
|
|
381
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# --- Config Viewer ---
|
|
386
|
+
@admin_router.get("/config/view")
|
|
387
|
+
async def get_all_configs(admin_user: str = Depends(require_admin)):
|
|
388
|
+
"""Get all configuration values for admin viewing."""
|
|
389
|
+
try:
|
|
390
|
+
# Get all configs from config manager
|
|
391
|
+
app_settings = config_manager.app_settings
|
|
392
|
+
llm_config = config_manager.llm_config
|
|
393
|
+
mcp_config = config_manager.mcp_config
|
|
394
|
+
|
|
395
|
+
# Convert app_settings to dict, excluding sensitive fields
|
|
396
|
+
app_settings_dict = app_settings.model_dump()
|
|
397
|
+
|
|
398
|
+
# Mask sensitive fields
|
|
399
|
+
sensitive_fields = ['api_key', 'secret', 'password', 'token']
|
|
400
|
+
for key, value in app_settings_dict.items():
|
|
401
|
+
if any(sensitive in key.lower() for sensitive in sensitive_fields):
|
|
402
|
+
if isinstance(value, str) and value:
|
|
403
|
+
app_settings_dict[key] = "***MASKED***"
|
|
404
|
+
|
|
405
|
+
# Convert LLM config, masking API keys
|
|
406
|
+
llm_config_dict = llm_config.model_dump()
|
|
407
|
+
if 'models' in llm_config_dict:
|
|
408
|
+
for model_name, model_config in llm_config_dict['models'].items():
|
|
409
|
+
if 'api_key' in model_config and model_config['api_key']:
|
|
410
|
+
model_config['api_key'] = "***MASKED***"
|
|
411
|
+
|
|
412
|
+
# Convert MCP config
|
|
413
|
+
mcp_config_dict = mcp_config.model_dump()
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
"app_settings": app_settings_dict,
|
|
417
|
+
"llm_config": llm_config_dict,
|
|
418
|
+
"mcp_config": mcp_config_dict,
|
|
419
|
+
"config_validation": config_manager.validate_config()
|
|
420
|
+
}
|
|
421
|
+
except Exception as e:
|
|
422
|
+
logger.error(f"Error getting config view: {e}")
|
|
423
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# --- Log Management ---
|
|
427
|
+
|
|
428
|
+
@admin_router.get("/logs/viewer")
|
|
429
|
+
async def get_enhanced_logs(
|
|
430
|
+
lines: int = 500,
|
|
431
|
+
level_filter: Optional[str] = None,
|
|
432
|
+
module_filter: Optional[str] = None,
|
|
433
|
+
admin_user: str = Depends(require_admin), # noqa: ARG001 (enforces auth)
|
|
434
|
+
):
|
|
435
|
+
try:
|
|
436
|
+
base_dir = _log_base_dir()
|
|
437
|
+
log_file = base_dir / "app.jsonl"
|
|
438
|
+
if not log_file.exists():
|
|
439
|
+
print(f"Log file {log_file.absolute()} not found")
|
|
440
|
+
raise HTTPException(status_code=404, detail="Log file not found")
|
|
441
|
+
|
|
442
|
+
from collections import deque
|
|
443
|
+
entries: List[Dict[str, Any]] = []
|
|
444
|
+
modules: set[str] = set()
|
|
445
|
+
levels: set[str] = set()
|
|
446
|
+
|
|
447
|
+
try:
|
|
448
|
+
with log_file.open("r", encoding="utf-8") as f:
|
|
449
|
+
recent_lines = deque(f, maxlen=lines + 200)
|
|
450
|
+
import re
|
|
451
|
+
pattern = re.compile(
|
|
452
|
+
r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})[,\s-]*(\w+)[,\s-]*([^-]*)[,\s-]*(.*)"
|
|
453
|
+
)
|
|
454
|
+
for raw in recent_lines:
|
|
455
|
+
raw = raw.strip()
|
|
456
|
+
if not raw or raw == "NEW LOG":
|
|
457
|
+
continue
|
|
458
|
+
try:
|
|
459
|
+
entry = json.loads(raw)
|
|
460
|
+
processed = {
|
|
461
|
+
"timestamp": entry.get("timestamp", ""),
|
|
462
|
+
"level": entry.get("level", "UNKNOWN"),
|
|
463
|
+
"module": entry.get("module", entry.get("logger", "")),
|
|
464
|
+
"logger": entry.get("logger", ""),
|
|
465
|
+
"function": entry.get("function", ""),
|
|
466
|
+
"message": entry.get("message", ""),
|
|
467
|
+
"trace_id": entry.get("trace_id", ""),
|
|
468
|
+
"span_id": entry.get("span_id", ""),
|
|
469
|
+
"line": entry.get("line", ""),
|
|
470
|
+
"thread_name": entry.get("thread_name", ""),
|
|
471
|
+
"extras": {k: v for k, v in entry.items() if k.startswith("extra_")},
|
|
472
|
+
}
|
|
473
|
+
except json.JSONDecodeError:
|
|
474
|
+
m = pattern.match(raw)
|
|
475
|
+
if m:
|
|
476
|
+
ts, lvl, mod, msg = m.groups()
|
|
477
|
+
processed = {
|
|
478
|
+
"timestamp": ts.strip(),
|
|
479
|
+
"level": lvl.strip().upper(),
|
|
480
|
+
"module": mod.strip(),
|
|
481
|
+
"logger": mod.strip(),
|
|
482
|
+
"function": "",
|
|
483
|
+
"message": msg.strip(),
|
|
484
|
+
"trace_id": "",
|
|
485
|
+
"span_id": "",
|
|
486
|
+
"line": "",
|
|
487
|
+
"thread_name": "",
|
|
488
|
+
"extras": {},
|
|
489
|
+
}
|
|
490
|
+
else:
|
|
491
|
+
processed = {
|
|
492
|
+
"timestamp": "",
|
|
493
|
+
"level": "INFO",
|
|
494
|
+
"module": "unknown",
|
|
495
|
+
"logger": "unknown",
|
|
496
|
+
"function": "",
|
|
497
|
+
"message": raw,
|
|
498
|
+
"trace_id": "",
|
|
499
|
+
"span_id": "",
|
|
500
|
+
"line": "",
|
|
501
|
+
"thread_name": "",
|
|
502
|
+
"extras": {},
|
|
503
|
+
}
|
|
504
|
+
if level_filter and processed["level"] != level_filter:
|
|
505
|
+
continue
|
|
506
|
+
if module_filter and processed["module"] != module_filter:
|
|
507
|
+
continue
|
|
508
|
+
entries.append(processed)
|
|
509
|
+
modules.add(processed["module"])
|
|
510
|
+
levels.add(processed["level"])
|
|
511
|
+
if len(entries) >= lines:
|
|
512
|
+
break
|
|
513
|
+
except Exception as e: # noqa: BLE001
|
|
514
|
+
logger.error(f"Error reading log file {log_file}: {e}")
|
|
515
|
+
entries = [
|
|
516
|
+
{
|
|
517
|
+
"timestamp": "",
|
|
518
|
+
"level": "ERROR",
|
|
519
|
+
"module": "admin",
|
|
520
|
+
"logger": "admin",
|
|
521
|
+
"function": "get_enhanced_logs",
|
|
522
|
+
"message": "An internal error occurred while reading log file.",
|
|
523
|
+
"trace_id": "",
|
|
524
|
+
"span_id": "",
|
|
525
|
+
"line": "",
|
|
526
|
+
"thread_name": "",
|
|
527
|
+
"extras": {},
|
|
528
|
+
}
|
|
529
|
+
]
|
|
530
|
+
modules = {"admin"}
|
|
531
|
+
levels = {"ERROR"}
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
"entries": entries,
|
|
535
|
+
"metadata": {
|
|
536
|
+
"total_entries": len(entries),
|
|
537
|
+
"unique_modules": sorted(modules),
|
|
538
|
+
"unique_levels": sorted(levels),
|
|
539
|
+
"log_file_path": str(log_file),
|
|
540
|
+
"requested_lines": lines,
|
|
541
|
+
"filters_applied": {"level": level_filter, "module": module_filter},
|
|
542
|
+
},
|
|
543
|
+
}
|
|
544
|
+
except Exception as e: # noqa: BLE001
|
|
545
|
+
logger.error(f"Error getting enhanced logs: {e}")
|
|
546
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
@admin_router.post("/logs/clear")
|
|
550
|
+
async def clear_app_logs(admin_user: str = Depends(require_admin)):
|
|
551
|
+
try:
|
|
552
|
+
base = _log_base_dir()
|
|
553
|
+
candidates = [
|
|
554
|
+
base / "app.jsonl",
|
|
555
|
+
base / "app.log",
|
|
556
|
+
Path("logs/app.jsonl"), # explicit root fallback
|
|
557
|
+
Path("logs/app.log"), # explicit root fallback
|
|
558
|
+
Path("backend/logs/app.jsonl"), # legacy
|
|
559
|
+
Path("backend/logs/app.log"), # legacy
|
|
560
|
+
Path("runtime/logs/app.jsonl"), # legacy
|
|
561
|
+
Path("runtime/logs/app.log"), # legacy,
|
|
562
|
+
]
|
|
563
|
+
cleared: List[str] = []
|
|
564
|
+
for f in candidates:
|
|
565
|
+
if f.exists():
|
|
566
|
+
try:
|
|
567
|
+
f.write_text("NEW LOG\n", encoding="utf-8")
|
|
568
|
+
cleared.append(str(f))
|
|
569
|
+
except Exception as e: # noqa: BLE001
|
|
570
|
+
logger.error(f"Failed clearing {f}: {e}")
|
|
571
|
+
if not cleared:
|
|
572
|
+
return {"message": "No log files found to clear", "cleared_by": admin_user, "files_cleared": []}
|
|
573
|
+
sanitized_admin_user = sanitize_for_logging(admin_user)
|
|
574
|
+
logger.info(f"Log files cleared by {sanitized_admin_user}: {cleared}")
|
|
575
|
+
return {"message": "Log files cleared successfully", "cleared_by": admin_user, "files_cleared": cleared}
|
|
576
|
+
except Exception as e: # noqa: BLE001
|
|
577
|
+
logger.error(f"Error clearing logs: {e}")
|
|
578
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
@admin_router.get("/logs/download")
|
|
582
|
+
async def download_logs(admin_user: str = Depends(require_admin)):
|
|
583
|
+
"""Download the raw application log file.
|
|
584
|
+
|
|
585
|
+
Frontend sets a custom filename via the anchor `download` attribute, so we just
|
|
586
|
+
stream the file with a generic name. Uses same discovery logic as log viewer.
|
|
587
|
+
"""
|
|
588
|
+
try:
|
|
589
|
+
log_file = _locate_log_file()
|
|
590
|
+
# Choose media type: jsonl logs are still plain text; no compression here.
|
|
591
|
+
media_type = "application/json" if log_file.suffix == ".jsonl" else "text/plain"
|
|
592
|
+
return FileResponse(
|
|
593
|
+
path=str(log_file),
|
|
594
|
+
media_type=media_type,
|
|
595
|
+
filename=log_file.name,
|
|
596
|
+
)
|
|
597
|
+
except HTTPException:
|
|
598
|
+
raise
|
|
599
|
+
except Exception as e: # noqa: BLE001
|
|
600
|
+
logger.error(f"Error preparing log download: {e}")
|
|
601
|
+
raise HTTPException(status_code=500, detail="Error preparing log download")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
# --- System Status (minimal) ---
|
|
605
|
+
|
|
606
|
+
@admin_router.get("/system-status")
|
|
607
|
+
async def get_system_status(admin_user: str = Depends(require_admin)):
|
|
608
|
+
"""Minimal system status endpoint for the Admin UI.
|
|
609
|
+
|
|
610
|
+
Returns basic configuration and logging status; avoids heavy checks.
|
|
611
|
+
"""
|
|
612
|
+
try:
|
|
613
|
+
# Configuration status: overrides directory and file count
|
|
614
|
+
app_settings = config_manager.app_settings
|
|
615
|
+
overrides_root = Path(app_settings.app_config_overrides)
|
|
616
|
+
if not overrides_root.is_absolute():
|
|
617
|
+
project_root = _project_root()
|
|
618
|
+
overrides_root = project_root / overrides_root
|
|
619
|
+
overrides_root.mkdir(parents=True, exist_ok=True)
|
|
620
|
+
config_files = list(overrides_root.glob("*"))
|
|
621
|
+
config_status = "healthy" if config_files else "warning"
|
|
622
|
+
|
|
623
|
+
# Logging status
|
|
624
|
+
log_dir = _log_base_dir()
|
|
625
|
+
log_file = log_dir / "app.jsonl"
|
|
626
|
+
log_exists = log_file.exists()
|
|
627
|
+
logging_status = "healthy" if log_exists else "warning"
|
|
628
|
+
|
|
629
|
+
components = [
|
|
630
|
+
{
|
|
631
|
+
"component": "Configuration",
|
|
632
|
+
"status": config_status,
|
|
633
|
+
"details": {
|
|
634
|
+
"overrides_dir": str(overrides_root),
|
|
635
|
+
"files_count": len(config_files),
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
"component": "Logging",
|
|
640
|
+
"status": logging_status,
|
|
641
|
+
"details": {
|
|
642
|
+
"log_file": str(log_file),
|
|
643
|
+
"exists": log_exists,
|
|
644
|
+
"size_bytes": log_file.stat().st_size if log_exists else 0,
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
]
|
|
648
|
+
|
|
649
|
+
overall = "healthy" if all(c["status"] == "healthy" for c in components) else "warning"
|
|
650
|
+
return {
|
|
651
|
+
"overall_status": overall,
|
|
652
|
+
"components": components,
|
|
653
|
+
"checked_by": admin_user,
|
|
654
|
+
}
|
|
655
|
+
except Exception as e: # noqa: BLE001
|
|
656
|
+
logger.error(f"Error getting system status: {e}")
|
|
657
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
# --- MCP Server Management ---
|
|
661
|
+
|
|
662
|
+
@admin_router.get("/mcp/available-servers")
|
|
663
|
+
async def get_available_mcp_servers(
|
|
664
|
+
admin_user: str = Depends(require_admin), # noqa: ARG001 (enforces auth)
|
|
665
|
+
):
|
|
666
|
+
"""Get all available MCP servers from the example-configs directory."""
|
|
667
|
+
try:
|
|
668
|
+
project_root = _project_root()
|
|
669
|
+
example_configs_dir = project_root / "config" / "mcp-example-configs"
|
|
670
|
+
|
|
671
|
+
if not example_configs_dir.exists():
|
|
672
|
+
return {"available_servers": {}}
|
|
673
|
+
|
|
674
|
+
available_servers = {}
|
|
675
|
+
|
|
676
|
+
for config_file in example_configs_dir.glob("mcp-*.json"):
|
|
677
|
+
try:
|
|
678
|
+
with config_file.open("r", encoding="utf-8") as f:
|
|
679
|
+
config_data = json.load(f)
|
|
680
|
+
|
|
681
|
+
# Each file should contain one server config
|
|
682
|
+
for server_name, server_config in config_data.items():
|
|
683
|
+
available_servers[server_name] = {
|
|
684
|
+
"config": server_config,
|
|
685
|
+
"source_file": config_file.name,
|
|
686
|
+
"description": server_config.get("description", ""),
|
|
687
|
+
"short_description": server_config.get("short_description", ""),
|
|
688
|
+
"author": server_config.get("author", ""),
|
|
689
|
+
"compliance_level": server_config.get("compliance_level", "")
|
|
690
|
+
}
|
|
691
|
+
except (json.JSONDecodeError, Exception) as e:
|
|
692
|
+
logger.warning(f"Failed to parse {config_file.name}: {e}")
|
|
693
|
+
continue
|
|
694
|
+
|
|
695
|
+
return {"available_servers": available_servers}
|
|
696
|
+
|
|
697
|
+
except Exception as e:
|
|
698
|
+
logger.error(f"Error getting available MCP servers: {e}")
|
|
699
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
@admin_router.get("/mcp/active-servers")
|
|
703
|
+
async def get_active_mcp_servers(
|
|
704
|
+
admin_user: str = Depends(require_admin), # noqa: ARG001 (enforces auth)
|
|
705
|
+
):
|
|
706
|
+
"""Get currently active MCP servers from the overrides/mcp.json file."""
|
|
707
|
+
try:
|
|
708
|
+
mcp_config_path = get_admin_config_path("mcp.json")
|
|
709
|
+
|
|
710
|
+
if not mcp_config_path.exists():
|
|
711
|
+
return {"active_servers": {}}
|
|
712
|
+
|
|
713
|
+
with mcp_config_path.open("r", encoding="utf-8") as f:
|
|
714
|
+
active_config = json.load(f)
|
|
715
|
+
|
|
716
|
+
return {"active_servers": active_config}
|
|
717
|
+
|
|
718
|
+
except Exception as e:
|
|
719
|
+
logger.error(f"Error getting active MCP servers: {e}")
|
|
720
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
@admin_router.post("/mcp/add-server")
|
|
724
|
+
async def add_mcp_server(
|
|
725
|
+
action: MCPServerAction,
|
|
726
|
+
admin_user: str = Depends(require_admin), # noqa: ARG001 (enforces auth)
|
|
727
|
+
):
|
|
728
|
+
"""Add an MCP server from example-configs to the active configuration."""
|
|
729
|
+
try:
|
|
730
|
+
server_name = action.server_name
|
|
731
|
+
|
|
732
|
+
# Get the server config from example-configs
|
|
733
|
+
project_root = _project_root()
|
|
734
|
+
example_configs_dir = project_root / "config" / "mcp-example-configs"
|
|
735
|
+
|
|
736
|
+
server_config = None
|
|
737
|
+
for config_file in example_configs_dir.glob("mcp-*.json"):
|
|
738
|
+
try:
|
|
739
|
+
with config_file.open("r", encoding="utf-8") as f:
|
|
740
|
+
config_data = json.load(f)
|
|
741
|
+
|
|
742
|
+
if server_name in config_data:
|
|
743
|
+
server_config = config_data[server_name]
|
|
744
|
+
break
|
|
745
|
+
except (json.JSONDecodeError, Exception):
|
|
746
|
+
continue
|
|
747
|
+
|
|
748
|
+
if not server_config:
|
|
749
|
+
raise HTTPException(
|
|
750
|
+
status_code=404,
|
|
751
|
+
detail=f"Server '{server_name}' not found in example configurations"
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
# Load current active configuration
|
|
755
|
+
mcp_config_path = get_admin_config_path("mcp.json")
|
|
756
|
+
|
|
757
|
+
if mcp_config_path.exists():
|
|
758
|
+
with mcp_config_path.open("r", encoding="utf-8") as f:
|
|
759
|
+
active_config = json.load(f)
|
|
760
|
+
else:
|
|
761
|
+
active_config = {}
|
|
762
|
+
|
|
763
|
+
# Check if server is already active
|
|
764
|
+
if server_name in active_config:
|
|
765
|
+
return {
|
|
766
|
+
"message": f"Server '{server_name}' is already active",
|
|
767
|
+
"server_name": server_name,
|
|
768
|
+
"already_active": True
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
# Add the server to active configuration
|
|
772
|
+
active_config[server_name] = server_config
|
|
773
|
+
|
|
774
|
+
# Save the updated configuration
|
|
775
|
+
with mcp_config_path.open("w", encoding="utf-8") as f:
|
|
776
|
+
json.dump(active_config, f, indent=2)
|
|
777
|
+
|
|
778
|
+
sanitized_admin_user = sanitize_for_logging(admin_user)
|
|
779
|
+
sanitized_server_name = sanitize_for_logging(server_name)
|
|
780
|
+
logger.info(f"Admin {sanitized_admin_user} added MCP server '{sanitized_server_name}' to active configuration")
|
|
781
|
+
|
|
782
|
+
# Trigger MCP reload to apply changes
|
|
783
|
+
try:
|
|
784
|
+
mcp_manager = app_factory.get_mcp_manager()
|
|
785
|
+
if mcp_manager:
|
|
786
|
+
await mcp_manager.reload_servers()
|
|
787
|
+
except Exception as reload_error:
|
|
788
|
+
sanitized_server_name = sanitize_for_logging(server_name)
|
|
789
|
+
logger.warning(f"Failed to reload MCP servers after adding '{sanitized_server_name}': {reload_error}")
|
|
790
|
+
|
|
791
|
+
return {
|
|
792
|
+
"message": f"Server '{server_name}' added successfully",
|
|
793
|
+
"server_name": server_name,
|
|
794
|
+
"config": server_config
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
except HTTPException:
|
|
798
|
+
raise
|
|
799
|
+
except Exception as e:
|
|
800
|
+
sanitized_server_name = sanitize_for_logging(action.server_name)
|
|
801
|
+
logger.error(f"Error adding MCP server '{sanitized_server_name}': {e}")
|
|
802
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
@admin_router.post("/mcp/remove-server")
|
|
806
|
+
async def remove_mcp_server(
|
|
807
|
+
action: MCPServerAction,
|
|
808
|
+
admin_user: str = Depends(require_admin), # noqa: ARG001 (enforces auth)
|
|
809
|
+
):
|
|
810
|
+
"""Remove an MCP server from the active configuration."""
|
|
811
|
+
try:
|
|
812
|
+
server_name = action.server_name
|
|
813
|
+
|
|
814
|
+
# Load current active configuration
|
|
815
|
+
mcp_config_path = get_admin_config_path("mcp.json")
|
|
816
|
+
|
|
817
|
+
if not mcp_config_path.exists():
|
|
818
|
+
raise HTTPException(
|
|
819
|
+
status_code=404,
|
|
820
|
+
detail="MCP configuration file not found"
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
with mcp_config_path.open("r", encoding="utf-8") as f:
|
|
824
|
+
active_config = json.load(f)
|
|
825
|
+
|
|
826
|
+
# Check if server exists in active configuration
|
|
827
|
+
if server_name not in active_config:
|
|
828
|
+
return {
|
|
829
|
+
"message": f"Server '{server_name}' is not currently active",
|
|
830
|
+
"server_name": server_name,
|
|
831
|
+
"not_active": True
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
# Remove the server from active configuration
|
|
835
|
+
removed_config = active_config.pop(server_name)
|
|
836
|
+
|
|
837
|
+
# Save the updated configuration
|
|
838
|
+
with mcp_config_path.open("w", encoding="utf-8") as f:
|
|
839
|
+
json.dump(active_config, f, indent=2)
|
|
840
|
+
|
|
841
|
+
sanitized_admin_user = sanitize_for_logging(admin_user)
|
|
842
|
+
sanitized_server_name = sanitize_for_logging(server_name)
|
|
843
|
+
logger.info(f"Admin {sanitized_admin_user} removed MCP server '{sanitized_server_name}' from active configuration")
|
|
844
|
+
|
|
845
|
+
# Trigger MCP reload to apply changes
|
|
846
|
+
try:
|
|
847
|
+
mcp_manager = app_factory.get_mcp_manager()
|
|
848
|
+
if mcp_manager:
|
|
849
|
+
await mcp_manager.reload_servers()
|
|
850
|
+
except Exception as reload_error:
|
|
851
|
+
sanitized_server_name = sanitize_for_logging(server_name)
|
|
852
|
+
logger.warning(f"Failed to reload MCP servers after removing '{sanitized_server_name}': {reload_error}")
|
|
853
|
+
|
|
854
|
+
return {
|
|
855
|
+
"message": f"Server '{server_name}' removed successfully",
|
|
856
|
+
"server_name": server_name,
|
|
857
|
+
"removed_config": removed_config
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
except HTTPException:
|
|
861
|
+
raise
|
|
862
|
+
except Exception as e:
|
|
863
|
+
sanitized_server_name = sanitize_for_logging(action.server_name)
|
|
864
|
+
logger.error(f"Error removing MCP server '{sanitized_server_name}': {e}")
|
|
865
|
+
raise HTTPException(status_code=500, detail=str(e))
|