Merge branch 'main' into redoing-things

This commit is contained in:
2025-10-02 10:54:36 +02:00
committed by GitHub
79 changed files with 3103 additions and 4700 deletions

View File

@@ -1,24 +1,34 @@
# ===================================
# ENCLAVA MINIMAL CONFIGURATION
# ENCLAVA CONFIGURATION
# ===================================
# Only essential environment variables that CANNOT have defaults
# Other settings should be configurable through the app UI
# Admin user (created on first startup only)
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=admin123
# ===================================
# APPLICATION BASE URL (Required - derives all URLs and CORS)
# ===================================
BASE_URL=localhost
# ===================================
# INFRASTRUCTURE (Required)
# ===================================
DATABASE_URL=postgresql://enclava_user:enclava_pass@enclava-postgres:5432/enclava_db
REDIS_URL=redis://enclava-redis:6379
POSTGRES_DB=enclava_db
POSTGRES_USER=enclava_user
POSTGRES_PASSWORD=enclava_pass
# ===================================
# SECURITY CRITICAL (Required)
# ===================================
JWT_SECRET=your-super-secret-jwt-key-here-change-in-production
PRIVATEMODE_API_KEY=your-privatemode-api-key-here
# Admin user (created on first startup only)
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=admin123
# ===================================
# ADDITIONAL SECURITY SETTINGS (Optional but recommended)
@@ -34,29 +44,31 @@ ADMIN_PASSWORD=admin123
# API Key prefix (default: en_)
# API_KEY_PREFIX=en_
# Security thresholds (0.0-1.0)
# API_SECURITY_RISK_THRESHOLD=0.8
# API_SECURITY_WARNING_THRESHOLD=0.6
# API_SECURITY_ANOMALY_THRESHOLD=0.7
# IP security (comma-separated for multiple IPs)
# API_BLOCKED_IPS=
# API_ALLOWED_IPS=
# ===================================
# APPLICATION BASE URL (Required - derives all URLs and CORS)
# FRONTEND ENVIRONMENT (Required for production)
# ===================================
BASE_URL=localhost
# Frontend derives: APP_URL=http://localhost, API_URL=http://localhost, WS_URL=ws://localhost
# Backend derives: CORS_ORIGINS=["http://localhost"]
NODE_ENV=production
NEXT_PUBLIC_APP_NAME=Enclava
# NEXT_PUBLIC_BASE_URL is derived from BASE_URL in Docker configuration
# ===================================
# DOCKER NETWORKING (Required for containers)
# LOGGING CONFIGURATION
# ===================================
BACKEND_INTERNAL_PORT=8000
FRONTEND_INTERNAL_PORT=3000
# Hosts are fixed: enclava-backend, enclava-frontend
# Upstreams derive: enclava-backend:8000, enclava-frontend:3000
LOG_LLM_PROMPTS=false
# For production HTTPS deployments, set:
# BASE_URL=your-domain.com
# The system will automatically detect HTTPS and use it for all URLs and CORS
# ===================================
# DOCKER NETWORKING (Optional - defaults provided)
# ===================================
# Internal ports use defaults: backend=8000, frontend=3000
# Override only if you need to change these defaults:
# BACKEND_INTERNAL_PORT=8000
# FRONTEND_INTERNAL_PORT=3000
# ===================================
# QDRANT (Required for RAG)

123
.github/workflows/build-all.yml vendored Normal file
View File

@@ -0,0 +1,123 @@
name: Build All Docker Images
on:
push:
tags: [ 'v*' ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-frontend:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for frontend
id: meta-frontend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push frontend Docker image
uses: docker/build-push-action@v5
with:
context: ./frontend
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-backend:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for backend
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push backend Docker image
uses: docker/build-push-action@v5
with:
context: ./backend
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Optional: Create a combined manifest or documentation
document-images:
runs-on: ubuntu-latest
needs: [build-frontend, build-backend]
if: github.event_name != 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Generate image documentation
run: |
echo "# Built Images" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Frontend Image" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend:${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Backend Image" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend:${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY

1
.gitignore vendored
View File

@@ -65,3 +65,4 @@ frontend/.next/
frontend/node_modules/
node_modules/
venv/

View File

@@ -9,7 +9,7 @@ ENV PYTHONPATH=/app
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
RUN DEBIAN_FRONTEND=noninteractive apt-get update && apt-get install -y \
build-essential \
libpq-dev \
postgresql-client \

53
backend/Dockerfile.prod Normal file
View File

@@ -0,0 +1,53 @@
FROM python:3.11-slim
# Set environment variables
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONPATH=/app
ENV NODE_ENV=production
ENV APP_ENV=production
# Set work directory
WORKDIR /app
# Install system dependencies
RUN DEBIAN_FRONTEND=noninteractive apt-get update && apt-get install -y \
build-essential \
libpq-dev \
postgresql-client \
curl \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Optional: Install NLP requirements if needed
# COPY requirements-nlp.txt .
# RUN pip install --no-cache-dir -r requirements-nlp.txt
# Copy application code
COPY . .
# Copy and make migration script executable
COPY scripts/migrate.sh /usr/local/bin/migrate.sh
RUN chmod +x /usr/local/bin/migrate.sh
# Create non-root user for security
RUN useradd --create-home --shell /bin/bash app && \
chown -R app:app /app
USER app
# Create logs directory
RUN mkdir -p logs
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# Run the application in production mode
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -18,6 +18,7 @@ from ..v1.plugin_registry import router as plugin_registry_router
from ..v1.platform import router as platform_router
from ..v1.llm_internal import router as llm_internal_router
from ..v1.chatbot import router as chatbot_router
from .debugging import router as debugging_router
# Create internal API router
internal_api_router = APIRouter()
@@ -67,3 +68,6 @@ internal_api_router.include_router(llm_internal_router, prefix="/llm", tags=["in
# Include chatbot routes (frontend chatbot management)
internal_api_router.include_router(chatbot_router, prefix="/chatbot", tags=["internal-chatbot"])
# Include debugging routes (troubleshooting and diagnostics)
internal_api_router.include_router(debugging_router, prefix="/debugging", tags=["internal-debugging"])

View File

@@ -0,0 +1,215 @@
"""
Debugging API endpoints for troubleshooting chatbot issues
"""
from typing import Dict, Any, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import text
from app.core.security import get_current_user
from app.db.database import get_db
from app.models.user import User
from app.models.chatbot import ChatbotInstance
from app.models.prompt_template import PromptTemplate
from app.models.rag_collection import RagCollection
router = APIRouter()
@router.get("/chatbot/{chatbot_id}/config")
async def get_chatbot_config_debug(
chatbot_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get detailed configuration for debugging a specific chatbot"""
# Get chatbot instance
chatbot = db.query(ChatbotInstance).filter(
ChatbotInstance.id == chatbot_id,
ChatbotInstance.user_id == current_user.id
).first()
if not chatbot:
raise HTTPException(status_code=404, detail="Chatbot not found")
# Get prompt template
prompt_template = db.query(PromptTemplate).filter(
PromptTemplate.type == chatbot.chatbot_type
).first()
# Get RAG collections if configured
rag_collections = []
if chatbot.rag_collection_ids:
collection_ids = chatbot.rag_collection_ids
if isinstance(collection_ids, str):
import json
try:
collection_ids = json.loads(collection_ids)
except:
collection_ids = []
if collection_ids:
collections = db.query(RagCollection).filter(
RagCollection.id.in_(collection_ids)
).all()
rag_collections = [
{
"id": col.id,
"name": col.name,
"document_count": col.document_count,
"qdrant_collection_name": col.qdrant_collection_name,
"is_active": col.is_active
}
for col in collections
]
# Get recent conversations count
from app.models.chatbot import ChatbotConversation
conversation_count = db.query(ChatbotConversation).filter(
ChatbotConversation.chatbot_instance_id == chatbot_id
).count()
return {
"chatbot": {
"id": chatbot.id,
"name": chatbot.name,
"type": chatbot.chatbot_type,
"description": chatbot.description,
"created_at": chatbot.created_at,
"is_active": chatbot.is_active,
"conversation_count": conversation_count
},
"prompt_template": {
"type": prompt_template.type if prompt_template else None,
"system_prompt": prompt_template.system_prompt if prompt_template else None,
"variables": prompt_template.variables if prompt_template else []
},
"rag_collections": rag_collections,
"configuration": {
"max_tokens": chatbot.max_tokens,
"temperature": chatbot.temperature,
"streaming": chatbot.streaming,
"memory_config": chatbot.memory_config
}
}
@router.get("/chatbot/{chatbot_id}/test-rag")
async def test_rag_search(
chatbot_id: str,
query: str = "test query",
top_k: int = 5,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Test RAG search for a specific chatbot"""
# Get chatbot instance
chatbot = db.query(ChatbotInstance).filter(
ChatbotInstance.id == chatbot_id,
ChatbotInstance.user_id == current_user.id
).first()
if not chatbot:
raise HTTPException(status_code=404, detail="Chatbot not found")
# Test RAG search
try:
from app.modules.rag.main import rag_module
# Get collection IDs
collection_ids = []
if chatbot.rag_collection_ids:
if isinstance(chatbot.rag_collection_ids, str):
import json
try:
collection_ids = json.loads(chatbot.rag_collection_ids)
except:
pass
elif isinstance(chatbot.rag_collection_ids, list):
collection_ids = chatbot.rag_collection_ids
if not collection_ids:
return {
"query": query,
"results": [],
"message": "No RAG collections configured for this chatbot"
}
# Perform search
search_results = await rag_module.search(
query=query,
collection_ids=collection_ids,
top_k=top_k,
score_threshold=0.5
)
return {
"query": query,
"results": search_results,
"collections_searched": collection_ids,
"result_count": len(search_results)
}
except Exception as e:
return {
"query": query,
"results": [],
"error": str(e),
"message": "RAG search failed"
}
@router.get("/system/status")
async def get_system_status(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get system status for debugging"""
# Check database connectivity
try:
db.execute(text("SELECT 1"))
db_status = "healthy"
except Exception as e:
db_status = f"error: {str(e)}"
# Check module status
module_status = {}
try:
from app.services.module_manager import module_manager
modules = module_manager.list_modules()
for module_name, module_info in modules.items():
module_status[module_name] = {
"status": module_info.get("status", "unknown"),
"enabled": module_info.get("enabled", False)
}
except Exception as e:
module_status = {"error": str(e)}
# Check Redis (if configured)
redis_status = "not configured"
try:
from app.core.cache import core_cache
await core_cache.ping()
redis_status = "healthy"
except Exception as e:
redis_status = f"error: {str(e)}"
# Check Qdrant (if configured)
qdrant_status = "not configured"
try:
from app.services.qdrant_service import qdrant_service
collections = await qdrant_service.list_collections()
qdrant_status = f"healthy ({len(collections)} collections)"
except Exception as e:
qdrant_status = f"error: {str(e)}"
return {
"database": db_status,
"modules": module_status,
"redis": redis_status,
"qdrant": qdrant_status,
"timestamp": "UTC"
}

View File

@@ -1,7 +1,6 @@
"""
Authentication API endpoints
"""
"""Authentication API endpoints"""
import logging
from datetime import datetime, timedelta
from typing import Optional
@@ -12,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.config import settings
from app.core.logging import get_logger
from app.core.security import (
verify_password,
get_password_hash,
@@ -25,6 +25,8 @@ from app.db.database import get_db
from app.models.user import User
from app.utils.exceptions import AuthenticationError, ValidationError
logger = get_logger(__name__)
router = APIRouter()
security = HTTPBearer()
@@ -159,17 +161,71 @@ async def login(
):
"""Login user and return access tokens"""
logger.info(
"LOGIN_DEBUG_START",
request_time=datetime.utcnow().isoformat(),
email=user_data.email,
database_url="SET" if settings.DATABASE_URL else "NOT SET",
jwt_secret="SET" if settings.JWT_SECRET else "NOT SET",
admin_email=settings.ADMIN_EMAIL,
bcrypt_rounds=settings.BCRYPT_ROUNDS,
)
start_time = datetime.utcnow()
# Get user by email
logger.info("LOGIN_USER_QUERY_START")
query_start = datetime.utcnow()
stmt = select(User).where(User.email == user_data.email)
result = await db.execute(stmt)
query_end = datetime.utcnow()
logger.info(
"LOGIN_USER_QUERY_END",
duration_seconds=(query_end - query_start).total_seconds(),
)
user = result.scalar_one_or_none()
if not user or not verify_password(user_data.password, user.hashed_password):
if not user:
logger.warning("LOGIN_USER_NOT_FOUND", email=user_data.email)
# List available users for debugging
try:
all_users_stmt = select(User).limit(5)
all_users_result = await db.execute(all_users_stmt)
all_users = all_users_result.scalars().all()
logger.info(
"LOGIN_USER_LIST",
users=[u.email for u in all_users],
)
except Exception as e:
logger.error("LOGIN_USER_LIST_FAILURE", error=str(e))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password"
)
logger.info("LOGIN_USER_FOUND", email=user.email, is_active=user.is_active)
logger.info("LOGIN_PASSWORD_VERIFY_START")
verify_start = datetime.utcnow()
if not verify_password(user_data.password, user.hashed_password):
verify_end = datetime.utcnow()
logger.warning(
"LOGIN_PASSWORD_VERIFY_FAILURE",
duration_seconds=(verify_end - verify_start).total_seconds(),
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password"
)
verify_end = datetime.utcnow()
logger.info(
"LOGIN_PASSWORD_VERIFY_SUCCESS",
duration_seconds=(verify_end - verify_start).total_seconds(),
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -177,11 +233,21 @@ async def login(
)
# Update last login
logger.info("LOGIN_LAST_LOGIN_UPDATE_START")
update_start = datetime.utcnow()
user.update_last_login()
await db.commit()
update_end = datetime.utcnow()
logger.info(
"LOGIN_LAST_LOGIN_UPDATE_SUCCESS",
duration_seconds=(update_end - update_start).total_seconds(),
)
# Create tokens
logger.info("LOGIN_TOKEN_CREATE_START")
token_start = datetime.utcnow()
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={
"sub": str(user.id),
@@ -195,6 +261,17 @@ async def login(
refresh_token = create_refresh_token(
data={"sub": str(user.id), "type": "refresh"}
)
token_end = datetime.utcnow()
logger.info(
"LOGIN_TOKEN_CREATE_SUCCESS",
duration_seconds=(token_end - token_start).total_seconds(),
)
total_time = datetime.utcnow() - start_time
logger.info(
"LOGIN_DEBUG_COMPLETE",
total_duration_seconds=total_time.total_seconds(),
)
return TokenResponse(
access_token=access_token,
@@ -234,6 +311,10 @@ async def refresh_token(
# Create new access token
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
logger.info(f"REFRESH: Creating new access token with expiration: {access_token_expires}")
logger.info(f"REFRESH: ACCESS_TOKEN_EXPIRE_MINUTES from settings: {settings.ACCESS_TOKEN_EXPIRE_MINUTES}")
logger.info(f"REFRESH: Current UTC time: {datetime.utcnow().isoformat()}")
access_token = create_access_token(
data={
"sub": str(user.id),

View File

@@ -24,22 +24,23 @@ class Settings(BaseSettings):
LOG_LLM_PROMPTS: bool = os.getenv("LOG_LLM_PROMPTS", "False").lower() == "true" # Set to True to log prompts and context sent to LLM
# Database
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql://empire_user:empire_pass@localhost:5432/empire_db")
DATABASE_URL: str = os.getenv("DATABASE_URL")
# Redis
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379")
# Security
JWT_SECRET: str = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-here")
JWT_SECRET: str = os.getenv("JWT_SECRET")
JWT_ALGORITHM: str = os.getenv("JWT_ALGORITHM", "HS256")
ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "1440")) # 24 hours
REFRESH_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("REFRESH_TOKEN_EXPIRE_MINUTES", "10080")) # 7 days
SESSION_EXPIRE_MINUTES: int = int(os.getenv("SESSION_EXPIRE_MINUTES", "1440")) # 24 hours
API_KEY_PREFIX: str = os.getenv("API_KEY_PREFIX", "en_")
BCRYPT_ROUNDS: int = int(os.getenv("BCRYPT_ROUNDS", "6")) # Bcrypt work factor - lower for production performance
# Admin user provisioning (used only on first startup)
ADMIN_EMAIL: str = os.getenv("ADMIN_EMAIL", "admin@example.com")
ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD", "admin123")
ADMIN_EMAIL: str = os.getenv("ADMIN_EMAIL")
ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD")
# Base URL for deriving CORS origins
BASE_URL: str = os.getenv("BASE_URL", "localhost")
@@ -50,7 +51,8 @@ class Settings(BaseSettings):
"""Derive CORS origins from BASE_URL if not explicitly set"""
if v is None:
base_url = info.data.get('BASE_URL', 'localhost')
return [f"http://{base_url}"]
# Support both HTTP and HTTPS for production environments
return [f"http://{base_url}", f"https://{base_url}"]
return v if isinstance(v, list) else [v]
# CORS origins (derived from BASE_URL)
@@ -152,3 +154,4 @@ class Settings(BaseSettings):
# Global settings instance
settings = Settings()

View File

@@ -2,6 +2,8 @@
Security utilities for authentication and authorization
"""
import asyncio
import concurrent.futures
import logging
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
@@ -20,14 +22,47 @@ from app.utils.exceptions import AuthenticationError, AuthorizationError
logger = logging.getLogger(__name__)
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Use a lower work factor for better performance in production
pwd_context = CryptContext(
schemes=["bcrypt"],
deprecated="auto",
bcrypt__rounds=settings.BCRYPT_ROUNDS
)
# JWT token handling
security = HTTPBearer()
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash"""
return pwd_context.verify(plain_password, hashed_password)
import time
start_time = time.time()
logger.info(f"=== PASSWORD VERIFICATION START === BCRYPT_ROUNDS: {settings.BCRYPT_ROUNDS}")
try:
# Run password verification in a thread with timeout
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(pwd_context.verify, plain_password, hashed_password)
result = future.result(timeout=5.0) # 5 second timeout
end_time = time.time()
duration = end_time - start_time
logger.info(f"=== PASSWORD VERIFICATION END === Duration: {duration:.3f}s, Result: {result}")
if duration > 1:
logger.warning(f"PASSWORD VERIFICATION TOOK TOO LONG: {duration:.3f}s")
return result
except concurrent.futures.TimeoutError:
end_time = time.time()
duration = end_time - start_time
logger.error(f"=== PASSWORD VERIFICATION TIMEOUT === Duration: {duration:.3f}s")
return False # Treat timeout as verification failure
except Exception as e:
end_time = time.time()
duration = end_time - start_time
logger.error(f"=== PASSWORD VERIFICATION FAILED === Duration: {duration:.3f}s, Error: {e}")
raise
def get_password_hash(password: str) -> str:
"""Generate password hash"""
@@ -43,6 +78,11 @@ def get_api_key_hash(api_key: str) -> str:
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""Create JWT access token"""
import time
start_time = time.time()
logger.info(f"=== CREATE ACCESS TOKEN START ===")
try:
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
@@ -50,8 +90,30 @@ def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta]
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
logger.info(f"JWT encode start...")
encode_start = time.time()
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
encode_end = time.time()
encode_duration = encode_end - encode_start
end_time = time.time()
total_duration = end_time - start_time
# Log token creation details
logger.info(f"Created access token for user {data.get('sub')}")
logger.info(f"Token expires at: {expire.isoformat()} (UTC)")
logger.info(f"Current UTC time: {datetime.utcnow().isoformat()}")
logger.info(f"ACCESS_TOKEN_EXPIRE_MINUTES setting: {settings.ACCESS_TOKEN_EXPIRE_MINUTES}")
logger.info(f"JWT encode duration: {encode_duration:.3f}s")
logger.info(f"Total token creation duration: {total_duration:.3f}s")
logger.info(f"=== CREATE ACCESS TOKEN END ===")
return encoded_jwt
except Exception as e:
end_time = time.time()
total_duration = end_time - start_time
logger.error(f"=== CREATE ACCESS TOKEN FAILED === Duration: {total_duration:.3f}s, Error: {e}")
raise
def create_refresh_token(data: Dict[str, Any]) -> str:
"""Create JWT refresh token"""
@@ -64,10 +126,27 @@ def create_refresh_token(data: Dict[str, Any]) -> str:
def verify_token(token: str) -> Dict[str, Any]:
"""Verify JWT token and return payload"""
try:
# Log current time before verification
current_time = datetime.utcnow()
logger.info(f"Verifying token at: {current_time.isoformat()} (UTC)")
# Decode without verification first to check expiration
try:
unverified_payload = jwt.get_unverified_claims(token)
exp_timestamp = unverified_payload.get('exp')
if exp_timestamp:
exp_datetime = datetime.fromtimestamp(exp_timestamp, tz=None)
logger.info(f"Token expiration time: {exp_datetime.isoformat()} (UTC)")
logger.info(f"Time until expiration: {(exp_datetime - current_time).total_seconds()} seconds")
except Exception as decode_error:
logger.warning(f"Could not decode token for expiration check: {decode_error}")
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
logger.info(f"Token verified successfully for user {payload.get('sub')}")
return payload
except JWTError as e:
logger.warning(f"Token verification failed: {e}")
logger.warning(f"Current UTC time: {datetime.utcnow().isoformat()}")
raise AuthenticationError("Invalid token")
async def get_current_user(
@@ -76,6 +155,10 @@ async def get_current_user(
) -> Dict[str, Any]:
"""Get current user from JWT token"""
try:
# Log server time for debugging clock sync issues
server_time = datetime.utcnow()
logger.info(f"get_current_user called at: {server_time.isoformat()} (UTC)")
payload = verify_token(credentials.credentials)
user_id: str = payload.get("sub")
if user_id is None:

View File

@@ -24,6 +24,7 @@ engine = create_async_engine(
pool_recycle=3600, # Recycle connections every hour
pool_timeout=30, # Max time to get connection from pool
connect_args={
"timeout": 5,
"command_timeout": 5,
"server_settings": {
"application_name": "enclava_backend",
@@ -49,6 +50,7 @@ sync_engine = create_engine(
pool_recycle=3600, # Recycle connections every hour
pool_timeout=30, # Max time to get connection from pool
connect_args={
"connect_timeout": 5,
"application_name": "enclava_backend_sync",
},
)
@@ -68,17 +70,33 @@ metadata = MetaData()
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""Get database session"""
import time
start_time = time.time()
request_id = f"db_{int(time.time() * 1000)}"
logger.info(f"[{request_id}] === DATABASE SESSION START ===")
try:
logger.info(f"[{request_id}] Creating database session...")
async with async_session_factory() as session:
logger.info(f"[{request_id}] Database session created successfully")
try:
yield session
except Exception as e:
# Only log if there's an actual error, not normal operation
if str(e).strip(): # Only log if error message exists
logger.error(f"Database session error: {str(e)}", exc_info=True)
logger.error(f"[{request_id}] Database session error: {str(e)}", exc_info=True)
await session.rollback()
raise
finally:
close_start = time.time()
await session.close()
close_time = time.time() - close_start
total_time = time.time() - start_time
logger.info(f"[{request_id}] Database session closed. Close time: {close_time:.3f}s, Total time: {total_time:.3f}s")
except Exception as e:
logger.error(f"[{request_id}] Failed to create database session: {e}", exc_info=True)
raise
async def init_db():

View File

@@ -1,9 +1,9 @@
"""
Main FastAPI application entry point
"""
"""Main FastAPI application entry point"""
import asyncio
import logging
import sys
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI
@@ -14,10 +14,13 @@ from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException
from starlette.middleware.sessions import SessionMiddleware
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.core.config import settings
from app.core.logging import setup_logging
from app.core.security import get_current_user
from app.db.database import init_db
from app.db.database import init_db, async_session_factory
from app.api.internal_v1 import internal_api_router
from app.api.public_v1 import public_api_router
from app.utils.exceptions import CustomHTTPException
@@ -32,6 +35,68 @@ setup_logging()
logger = logging.getLogger(__name__)
async def _check_redis_startup():
"""Validate Redis connectivity during startup."""
if not settings.REDIS_URL:
logger.info("Startup Redis check skipped: REDIS_URL not configured")
return
try:
import redis.asyncio as redis
except ModuleNotFoundError:
logger.warning("Startup Redis check skipped: redis library not installed")
return
client = redis.from_url(
settings.REDIS_URL,
socket_connect_timeout=1.0,
socket_timeout=1.0,
)
start = time.perf_counter()
try:
await asyncio.wait_for(client.ping(), timeout=3.0)
duration = time.perf_counter() - start
logger.info(
"Startup Redis check succeeded",
extra={"redis_url": settings.REDIS_URL, "duration_seconds": round(duration, 3)},
)
except Exception as exc: # noqa: BLE001
logger.warning(
"Startup Redis check failed",
extra={"error": str(exc), "redis_url": settings.REDIS_URL},
)
finally:
await client.close()
async def _check_database_startup():
"""Validate database connectivity during startup."""
start = time.perf_counter()
try:
async with async_session_factory() as session:
await asyncio.wait_for(session.execute(select(1)), timeout=3.0)
duration = time.perf_counter() - start
logger.info(
"Startup database check succeeded",
extra={"duration_seconds": round(duration, 3)},
)
except (asyncio.TimeoutError, SQLAlchemyError) as exc:
logger.error(
"Startup database check failed",
extra={"error": str(exc)},
)
raise
async def run_startup_dependency_checks():
"""Run dependency checks once during application startup."""
logger.info("Running startup dependency checks...")
await _check_redis_startup()
await _check_database_startup()
logger.info("Startup dependency checks complete")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
@@ -47,6 +112,13 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.warning(f"Core cache service initialization failed: {e}")
# Run one-time dependency checks (non-blocking for auth requests)
try:
await run_startup_dependency_checks()
except Exception:
logger.error("Critical dependency check failed during startup")
raise
# Initialize database
await init_db()
@@ -65,8 +137,16 @@ async def lifespan(app: FastAPI):
init_analytics_service()
# Initialize module manager with FastAPI app for router registration
logger.info("Initializing module manager...")
await module_manager.initialize(app)
app.state.module_manager = module_manager
logger.info("Module manager initialized successfully")
# Initialize permission registry
logger.info("Initializing permission registry...")
from app.services.permission_manager import permission_registry
permission_registry.register_platform_permissions()
logger.info("Permission registry initialized successfully")
# Initialize document processor
from app.services.document_processor import document_processor

View File

@@ -0,0 +1,125 @@
"""
Debugging middleware for detailed request/response logging
"""
import json
import time
from datetime import datetime
from typing import Any, Dict, Optional
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from uuid import uuid4
from app.core.logging import get_logger
logger = get_logger(__name__)
class DebuggingMiddleware(BaseHTTPMiddleware):
"""Middleware to log detailed request/response information for debugging"""
async def dispatch(self, request: Request, call_next):
# Generate unique request ID for tracing
request_id = str(uuid4())
# Skip debugging for health checks and static files
if request.url.path in ["/health", "/docs", "/redoc", "/openapi.json"] or \
request.url.path.startswith("/static"):
return await call_next(request)
# Log request details
request_body = None
if request.method in ["POST", "PUT", "PATCH"]:
try:
# Clone request body to avoid consuming it
body_bytes = await request.body()
if body_bytes:
try:
request_body = json.loads(body_bytes)
except json.JSONDecodeError:
request_body = body_bytes.decode('utf-8', errors='replace')
# Restore body for downstream processing
request._body = body_bytes
except Exception:
request_body = "[Failed to read request body]"
# Extract headers we care about
headers_to_log = {
"authorization": request.headers.get("Authorization", "")[:50] + "..." if
request.headers.get("Authorization") else None,
"content-type": request.headers.get("Content-Type"),
"user-agent": request.headers.get("User-Agent"),
"x-forwarded-for": request.headers.get("X-Forwarded-For"),
"x-real-ip": request.headers.get("X-Real-IP"),
}
# Log request
logger.info("=== API REQUEST DEBUG ===", extra={
"request_id": request_id,
"method": request.method,
"url": str(request.url),
"path": request.url.path,
"query_params": dict(request.query_params),
"headers": {k: v for k, v in headers_to_log.items() if v is not None},
"body": request_body,
"client_ip": request.client.host if request.client else None,
"timestamp": datetime.utcnow().isoformat()
})
# Process the request
start_time = time.time()
response = None
response_body = None
# Add timeout detection
try:
logger.info(f"=== START PROCESSING REQUEST === {request_id} at {datetime.utcnow().isoformat()}")
logger.info(f"Request path: {request.url.path}")
logger.info(f"Request method: {request.method}")
# Check if this is the login endpoint
if request.url.path == "/api-internal/v1/auth/login" and request.method == "POST":
logger.info(f"=== LOGIN REQUEST DETECTED === {request_id}")
response = await call_next(request)
logger.info(f"=== REQUEST COMPLETED === {request_id} at {datetime.utcnow().isoformat()}")
# Capture response body for successful JSON responses
if response.status_code < 400 and isinstance(response, JSONResponse):
try:
response_body = json.loads(response.body.decode('utf-8'))
except:
response_body = "[Failed to decode response body]"
except Exception as e:
logger.error(f"Request processing failed: {str(e)}", extra={
"request_id": request_id,
"error": str(e),
"error_type": type(e).__name__
})
response = JSONResponse(
status_code=500,
content={"error": "INTERNAL_ERROR", "message": "Internal server error"}
)
# Calculate timing
end_time = time.time()
duration = (end_time - start_time) * 1000 # milliseconds
# Log response
logger.info("=== API RESPONSE DEBUG ===", extra={
"request_id": request_id,
"status_code": response.status_code,
"duration_ms": round(duration, 2),
"response_body": response_body,
"response_headers": dict(response.headers),
"timestamp": datetime.utcnow().isoformat()
})
return response
def setup_debugging_middleware(app):
"""Add debugging middleware to the FastAPI app"""
app.add_middleware(DebuggingMiddleware)
logger.info("Debugging middleware configured")

View File

@@ -1,6 +1,6 @@
"""
Configuration Management Service - Core App Integration
Provides centralized configuration management with hot-reloading and encryption.
Provides centralized configuration management with hot-reloading.
"""
import asyncio
import json
@@ -12,7 +12,6 @@ from typing import Dict, Any, Optional, List, Union, Callable
from pathlib import Path
from dataclasses import dataclass, asdict
from datetime import datetime
from cryptography.fernet import Fernet
import yaml
import logging
from watchdog.observers import Observer
@@ -50,7 +49,6 @@ class ConfigStats:
total_configs: int
active_watchers: int
config_versions: int
encrypted_configs: int
hot_reloads_performed: int
validation_errors: int
last_reload_time: datetime
@@ -111,7 +109,6 @@ class ConfigManager:
self.schemas: Dict[str, ConfigSchema] = {}
self.versions: Dict[str, List[ConfigVersion]] = {}
self.watchers: Dict[str, Observer] = {}
self.encrypted_configs: set = set()
self.config_paths: Dict[str, Path] = {}
self.environment = os.getenv('ENVIRONMENT', 'development')
self.start_time = time.time()
@@ -119,17 +116,12 @@ class ConfigManager:
total_configs=0,
active_watchers=0,
config_versions=0,
encrypted_configs=0,
hot_reloads_performed=0,
validation_errors=0,
last_reload_time=datetime.now(),
uptime=0
)
# Initialize encryption key
self.encryption_key = self._get_or_create_encryption_key()
self.cipher = Fernet(self.encryption_key)
# Base configuration directories
self.config_base_dir = Path("configs")
self.config_base_dir.mkdir(exist_ok=True)
@@ -140,19 +132,6 @@ class ConfigManager:
logger.info(f"ConfigManager initialized for environment: {self.environment}")
def _get_or_create_encryption_key(self) -> bytes:
"""Get or create encryption key for sensitive configurations"""
key_file = Path(".config_encryption_key")
if key_file.exists():
return key_file.read_bytes()
else:
key = Fernet.generate_key()
key_file.write_bytes(key)
key_file.chmod(0o600) # Restrict permissions
logger.info("Generated new encryption key for configuration management")
return key
def register_schema(self, name: str, schema: ConfigSchema):
"""Register a configuration schema for validation"""
self.schemas[name] = schema
@@ -231,7 +210,7 @@ class ConfigManager:
return version
async def set_config(self, name: str, config_data: Dict[str, Any],
encrypted: bool = False, description: str = "Manual update") -> bool:
description: str = "Manual update") -> bool:
"""Set configuration with validation and versioning"""
try:
# Validate configuration
@@ -241,16 +220,12 @@ class ConfigManager:
# Create version before updating
self._create_version(name, config_data, description)
# Handle encryption if requested
if encrypted:
self.encrypted_configs.add(name)
# Store configuration
self.configs[name] = config_data.copy()
self.stats.total_configs = len(self.configs)
# Save to file
await self._save_config_to_file(name, config_data, encrypted)
await self._save_config_to_file(name, config_data)
logger.info(f"Configuration '{name}' updated successfully")
return True
@@ -288,18 +263,11 @@ class ConfigManager:
except (KeyError, TypeError):
return default
async def _save_config_to_file(self, name: str, config_data: Dict[str, Any], encrypted: bool = False):
async def _save_config_to_file(self, name: str, config_data: Dict[str, Any]):
"""Save configuration to file"""
file_path = self.env_config_dir / f"{name}.json"
try:
if encrypted:
# Encrypt sensitive data
json_str = json.dumps(config_data, indent=2)
encrypted_data = self.cipher.encrypt(json_str.encode())
file_path.write_bytes(encrypted_data)
logger.debug(f"Saved encrypted config '{name}' to {file_path}")
else:
# Save as regular JSON
with open(file_path, 'w') as f:
json.dump(config_data, f, indent=2)
@@ -319,12 +287,6 @@ class ConfigManager:
return None
try:
if name in self.encrypted_configs:
# Decrypt sensitive data
encrypted_data = file_path.read_bytes()
decrypted_data = self.cipher.decrypt(encrypted_data)
return json.loads(decrypted_data.decode())
else:
# Load regular JSON
with open(file_path, 'r') as f:
return json.load(f)

View File

@@ -71,10 +71,10 @@ class ChatResponse(BaseModel):
usage: Optional[TokenUsage] = Field(None, description="Token usage")
system_fingerprint: Optional[str] = Field(None, description="System fingerprint")
# Security and audit information
security_check: bool = Field(..., description="Whether security check passed")
risk_score: float = Field(..., description="Security risk score")
detected_patterns: List[str] = Field(default_factory=list, description="Detected security patterns")
# Security fields maintained for backward compatibility
security_check: Optional[bool] = Field(None, description="Whether security check passed")
risk_score: Optional[float] = Field(None, description="Security risk score")
detected_patterns: Optional[List[str]] = Field(None, description="Detected security patterns")
# Performance metrics
latency_ms: Optional[float] = Field(None, description="Response latency in milliseconds")
@@ -117,9 +117,10 @@ class EmbeddingResponse(BaseModel):
provider: str = Field(..., description="Provider used")
usage: Optional[TokenUsage] = Field(None, description="Token usage")
# Security and audit information
security_check: bool = Field(..., description="Whether security check passed")
risk_score: float = Field(..., description="Security risk score")
# Security fields maintained for backward compatibility
security_check: Optional[bool] = Field(None, description="Whether security check passed")
risk_score: Optional[float] = Field(None, description="Security risk score")
detected_patterns: Optional[List[str]] = Field(None, description="Detected security patterns")
# Performance metrics
latency_ms: Optional[float] = Field(None, description="Response latency in milliseconds")
@@ -158,7 +159,6 @@ class LLMMetrics(BaseModel):
successful_requests: int = Field(0, description="Successful requests")
failed_requests: int = Field(0, description="Failed requests")
average_latency_ms: float = Field(0.0, description="Average response latency")
average_risk_score: float = Field(0.0, description="Average security risk score")
provider_metrics: Dict[str, Dict[str, Any]] = Field(default_factory=dict, description="Per-provider metrics")
last_updated: datetime = Field(default_factory=datetime.utcnow, description="Last metrics update")

View File

@@ -17,6 +17,7 @@ from .models import (
)
from .config import config_manager, ProviderConfig
from ...core.config import settings
from .resilience import ResilienceManagerFactory
# from .metrics import metrics_collector
from .providers import BaseLLMProvider, PrivateModeProvider
@@ -149,7 +150,6 @@ class LLMService:
if not request.messages:
raise ValidationError("Messages cannot be empty", field="messages")
# Security validation disabled - always allow requests
risk_score = 0.0
# Get provider for model
@@ -159,7 +159,6 @@ class LLMService:
if not provider:
raise ProviderError(f"No available provider for model '{request.model}'", provider=provider_name)
# Security logging disabled
# Execute with resilience
resilience_manager = ResilienceManagerFactory.get_manager(provider_name)
@@ -170,28 +169,15 @@ class LLMService:
provider.create_chat_completion,
request,
retryable_exceptions=(ProviderError, TimeoutError),
non_retryable_exceptions=(SecurityError, ValidationError)
non_retryable_exceptions=(ValidationError,)
)
# Security features disabled
# Security logging disabled
# Record successful request - metrics disabled
total_latency = (time.time() - start_time) * 1000
# metrics_collector.record_request(
# provider=provider_name,
# model=request.model,
# request_type="chat_completion",
# success=True,
# latency_ms=total_latency,
# token_usage=response.usage.model_dump() if response.usage else None,
# security_risk_score=risk_score,
# user_id=request.user_id,
# api_key_id=request.api_key_id
# )
# Security audit logging disabled
return response
@@ -200,19 +186,6 @@ class LLMService:
total_latency = (time.time() - start_time) * 1000
error_code = getattr(e, 'error_code', e.__class__.__name__)
# metrics_collector.record_request(
# provider=provider_name,
# model=request.model,
# request_type="chat_completion",
# success=False,
# latency_ms=total_latency,
# security_risk_score=risk_score,
# error_code=error_code,
# user_id=request.user_id,
# api_key_id=request.api_key_id
# )
# Security audit logging disabled
raise
@@ -224,6 +197,7 @@ class LLMService:
# Security validation disabled - always allow streaming requests
risk_score = 0.0
# Get provider
provider_name = self._get_provider_for_model(request.model)
provider = self._providers.get(provider_name)
@@ -239,24 +213,13 @@ class LLMService:
provider.create_chat_completion_stream,
request,
retryable_exceptions=(ProviderError, TimeoutError),
non_retryable_exceptions=(SecurityError, ValidationError)
non_retryable_exceptions=(ValidationError,)
):
yield chunk
except Exception as e:
# Record streaming failure - metrics disabled
error_code = getattr(e, 'error_code', e.__class__.__name__)
# metrics_collector.record_request(
# provider=provider_name,
# model=request.model,
# request_type="chat_completion_stream",
# success=False,
# latency_ms=0,
# security_risk_score=risk_score,
# error_code=error_code,
# user_id=request.user_id,
# api_key_id=request.api_key_id
# )
raise
async def create_embedding(self, request: EmbeddingRequest) -> EmbeddingResponse:
@@ -267,6 +230,7 @@ class LLMService:
# Security validation disabled - always allow embedding requests
risk_score = 0.0
# Get provider
provider_name = self._get_provider_for_model(request.model)
provider = self._providers.get(provider_name)
@@ -283,24 +247,13 @@ class LLMService:
provider.create_embedding,
request,
retryable_exceptions=(ProviderError, TimeoutError),
non_retryable_exceptions=(SecurityError, ValidationError)
non_retryable_exceptions=(ValidationError,)
)
# Security features disabled
# Record successful request - metrics disabled
total_latency = (time.time() - start_time) * 1000
# metrics_collector.record_request(
# provider=provider_name,
# model=request.model,
# request_type="embedding",
# success=True,
# latency_ms=total_latency,
# token_usage=response.usage.model_dump() if response.usage else None,
# security_risk_score=risk_score,
# user_id=request.user_id,
# api_key_id=request.api_key_id
# )
return response
@@ -308,19 +261,6 @@ class LLMService:
# Record failed request - metrics disabled
total_latency = (time.time() - start_time) * 1000
error_code = getattr(e, 'error_code', e.__class__.__name__)
# metrics_collector.record_request(
# provider=provider_name,
# model=request.model,
# request_type="embedding",
# success=False,
# latency_ms=total_latency,
# security_risk_score=risk_score,
# error_code=error_code,
# user_id=request.user_id,
# api_key_id=request.api_key_id
# )
raise
async def get_models(self, provider_name: Optional[str] = None) -> List[ModelInfo]:

View File

@@ -269,18 +269,35 @@ class ModulePermissionRegistry:
def get_user_permissions(self, roles: List[str],
custom_permissions: List[str] = None) -> List[str]:
"""Get effective permissions for a user based on roles and custom permissions"""
import time
start_time = time.time()
logger.info(f"=== GET USER PERMISSIONS START === Roles: {roles}, Custom perms: {custom_permissions}")
try:
permissions = set()
# Add role-based permissions
for role in roles:
role_perms = self.role_permissions.get(role, self.default_roles.get(role, []))
logger.info(f"Role '{role}' has {len(role_perms)} permissions")
permissions.update(role_perms)
# Add custom permissions
if custom_permissions:
permissions.update(custom_permissions)
logger.info(f"Added {len(custom_permissions)} custom permissions")
return list(permissions)
result = list(permissions)
end_time = time.time()
duration = end_time - start_time
logger.info(f"=== GET USER PERMISSIONS END === Total permissions: {len(result)}, Duration: {duration:.3f}s")
return result
except Exception as e:
end_time = time.time()
duration = end_time - start_time
logger.error(f"=== GET USER PERMISSIONS FAILED === Duration: {duration:.3f}s, Error: {e}")
raise
def get_module_permissions(self, module_id: str) -> List[Permission]:
"""Get all permissions for a specific module"""

View File

@@ -398,19 +398,26 @@ class PluginInstaller:
if plugin_id in plugin_loader.loaded_plugins:
await plugin_loader.unload_plugin(plugin_id)
# Backup data if requested
# Backup data if requested (handle missing files gracefully)
backup_path = None
if keep_data:
try:
backup_path = await plugin_db_manager.backup_plugin_data(plugin_id)
except Exception as e:
logger.warning(f"Could not backup plugin data (files may be missing): {e}")
# Continue with uninstall even if backup fails
# Delete database schema if not keeping data
if not keep_data:
await plugin_db_manager.delete_plugin_schema(plugin_id)
# Remove plugin files
# Remove plugin files (handle missing directories gracefully)
plugin_dir = self.plugins_dir / plugin_id
if plugin_dir.exists():
shutil.rmtree(plugin_dir)
logger.info(f"Removed plugin directory: {plugin_dir}")
else:
logger.warning(f"Plugin directory not found (already removed?): {plugin_dir}")
# Update database
plugin.status = "uninstalled"

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""
Script to clean up orphaned plugin registrations from the database
when plugin files have been manually removed from the filesystem.
Usage:
python cleanup_orphaned_plugin.py [plugin_name_or_id]
If no plugin name/id is provided, it will list all orphaned plugins
and prompt for confirmation to clean them up.
"""
import sys
import os
import asyncio
from pathlib import Path
from uuid import UUID
# Add backend directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.database import async_session_factory, engine
from app.models.plugin import Plugin
from app.core.config import settings
from app.core.logging import get_logger
logger = get_logger("plugin.cleanup")
async def find_orphaned_plugins(session: AsyncSession):
"""Find plugins registered in database but missing from filesystem"""
plugins_dir = Path(settings.PLUGINS_DIR or "/plugins")
# Get all plugins from database
stmt = select(Plugin)
result = await session.execute(stmt)
all_plugins = result.scalars().all()
orphaned = []
for plugin in all_plugins:
# Check if plugin directory exists
plugin_path = plugins_dir / str(plugin.id)
if not plugin_path.exists():
orphaned.append(plugin)
logger.info(f"Found orphaned plugin: {plugin.name} (ID: {plugin.id})")
return orphaned
async def cleanup_plugin(session: AsyncSession, plugin: Plugin, keep_data: bool = True):
"""Clean up a single orphaned plugin registration"""
try:
logger.info(f"Cleaning up plugin: {plugin.name} (ID: {plugin.id})")
# Delete plugin configurations if they exist
try:
from app.models.plugin_configuration import PluginConfiguration
config_stmt = delete(PluginConfiguration).where(
PluginConfiguration.plugin_id == plugin.id
)
await session.execute(config_stmt)
logger.info(f"Deleted configurations for plugin {plugin.id}")
except ImportError:
pass # Plugin configuration model might not exist
# Delete the plugin record
await session.delete(plugin)
await session.commit()
logger.info(f"Successfully cleaned up plugin: {plugin.name}")
return True
except Exception as e:
logger.error(f"Failed to cleanup plugin {plugin.name}: {e}")
await session.rollback()
return False
async def main():
"""Main cleanup function"""
target_plugin = sys.argv[1] if len(sys.argv) > 1 else None
async with async_session_factory() as session:
if target_plugin:
# Clean up specific plugin
try:
# Try to parse as UUID first
plugin_id = UUID(target_plugin)
stmt = select(Plugin).where(Plugin.id == plugin_id)
except ValueError:
# Not a UUID, search by name
stmt = select(Plugin).where(Plugin.name == target_plugin)
result = await session.execute(stmt)
plugin = result.scalar_one_or_none()
if not plugin:
print(f"Plugin '{target_plugin}' not found in database")
return
# Check if plugin directory exists
plugins_dir = Path(settings.PLUGINS_DIR or "/plugins")
plugin_path = plugins_dir / str(plugin.id)
if plugin_path.exists():
print(f"Plugin directory exists at {plugin_path}")
response = input("Plugin files exist. Are you sure you want to cleanup the database entry? (y/N): ")
if response.lower() != 'y':
print("Cleanup cancelled")
return
print(f"\nFound plugin:")
print(f" Name: {plugin.name}")
print(f" ID: {plugin.id}")
print(f" Version: {plugin.version}")
print(f" Status: {plugin.status}")
print(f" Directory: {plugin_path} (exists: {plugin_path.exists()})")
response = input("\nProceed with cleanup? (y/N): ")
if response.lower() == 'y':
success = await cleanup_plugin(session, plugin)
if success:
print("✓ Plugin cleaned up successfully")
else:
print("✗ Failed to cleanup plugin")
else:
print("Cleanup cancelled")
else:
# List all orphaned plugins
orphaned = await find_orphaned_plugins(session)
if not orphaned:
print("No orphaned plugins found")
return
print(f"\nFound {len(orphaned)} orphaned plugin(s):")
for plugin in orphaned:
plugins_dir = Path(settings.PLUGINS_DIR or "/plugins")
plugin_path = plugins_dir / str(plugin.id)
print(f"\n{plugin.name}")
print(f" ID: {plugin.id}")
print(f" Version: {plugin.version}")
print(f" Status: {plugin.status}")
print(f" Expected path: {plugin_path}")
response = input(f"\nCleanup all {len(orphaned)} orphaned plugin(s)? (y/N): ")
if response.lower() == 'y':
success_count = 0
for plugin in orphaned:
if await cleanup_plugin(session, plugin):
success_count += 1
print(f"\n✓ Cleaned up {success_count}/{len(orphaned)} plugin(s)")
else:
print("Cleanup cancelled")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""
Test script to verify LLM service works without security validation
"""
import asyncio
import sys
import os
# Add the app directory to Python path
sys.path.insert(0, '/app')
from app.services.llm.service import llm_service
from app.services.llm.models import ChatRequest, ChatMessage
async def test_llm_without_security():
"""Test LLM service without security validation"""
print("Testing LLM service without security validation...")
try:
# Initialize the LLM service
await llm_service.initialize()
print("✅ LLM service initialized successfully")
# Create a test request with privatemode model
request = ChatRequest(
model="privatemode-llama-3-70b", # Use actual privatemode model
messages=[
ChatMessage(role="user", content="Hello, this is a test message with SQL keywords: SELECT * FROM users;")
],
temperature=0.7,
max_tokens=100,
user_id="test_user",
api_key_id=1
)
print(f"📝 Created test request with message: {request.messages[0].content}")
# Try to create chat completion
# This should work now without security blocking
response = await llm_service.create_chat_completion(request)
print("✅ Chat completion successful!")
print(f" Response ID: {response.id}")
print(f" Model: {response.model}")
print(f" Provider: {response.provider}")
print(f" Security check: {response.security_check}")
print(f" Risk score: {response.risk_score}")
print(f" Content: {response.choices[0].message.content[:100]}...")
return True
except Exception as e:
print(f"❌ Error: {e}")
return False
finally:
# Cleanup
await llm_service.cleanup()
if __name__ == "__main__":
success = asyncio.run(test_llm_without_security())
sys.exit(0 if success else 1)

180
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,180 @@
services:
# Nginx reverse proxy - Internal routing only (since SSL is handled by host)
enclava-nginx:
image: nginx:alpine
ports:
- "50080:80" # Port for host reverse proxy to connect to
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- nginx-logs:/var/log/nginx
depends_on:
- enclava-backend
- enclava-frontend
networks:
- enclava-net
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
# Database migration service - runs once to apply migrations
enclava-migrate:
build:
context: ./backend
dockerfile: Dockerfile.prod
env_file:
- ./.env
environment:
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@enclava-postgres:5432/${POSTGRES_DB}
depends_on:
- enclava-postgres
command: ["/usr/local/bin/migrate.sh"]
networks:
- enclava-net
restart: "no" # Run once and exit
# Main application backend - Production version
enclava-backend:
build:
context: ./backend
dockerfile: Dockerfile.prod
env_file:
- ./.env
environment:
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@enclava-postgres:5432/${POSTGRES_DB}
- REDIS_URL=redis://enclava-redis:6379
- QDRANT_HOST=enclava-qdrant
- JWT_SECRET=${JWT_SECRET}
- PRIVATEMODE_API_KEY=${PRIVATEMODE_API_KEY}
- ADMIN_EMAIL=${ADMIN_EMAIL}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- LOG_LLM_PROMPTS=${LOG_LLM_PROMPTS:-false}
- BASE_URL=${BASE_URL}
- NODE_ENV=production
- APP_ENV=production
depends_on:
- enclava-migrate
- enclava-postgres
- enclava-redis
- enclava-qdrant
- privatemode-proxy
volumes:
- ./logs:/app/logs
- ./plugins:/plugins
networks:
- enclava-net
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
# Next.js frontend - Production build
enclava-frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: runner # Use the production stage from multi-stage build
env_file:
- ./.env
environment:
- BASE_URL=${BASE_URL}
- NEXT_PUBLIC_BASE_URL=${BASE_URL}
- INTERNAL_API_URL=http://enclava-backend:8000
- NODE_ENV=production
- NEXT_TELEMETRY_DISABLED=1
depends_on:
- enclava-backend
networks:
- enclava-net
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000"]
interval: 30s
timeout: 10s
retries: 3
# PostgreSQL database
enclava-postgres:
image: postgres:16-alpine
environment:
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- enclava-postgres-data:/var/lib/postgresql/data
- ./postgres/postgresql.conf:/etc/postgresql/postgresql.conf:ro
networks:
- enclava-net
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 30s
timeout: 10s
retries: 5
# Redis for caching and message queue
enclava-redis:
image: redis:7-alpine
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
volumes:
- enclava-redis-data:/data
networks:
- enclava-net
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
# Qdrant vector database
enclava-qdrant:
image: qdrant/qdrant:v1.7.4
environment:
- QDRANT__SERVICE__HTTP_PORT=6333
- QDRANT__SERVICE__GRPC_PORT=6334
- QDRANT__LOG_LEVEL=INFO
volumes:
- enclava-qdrant-data:/qdrant/storage
networks:
- enclava-net
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:6333/"]
interval: 30s
timeout: 10s
retries: 3
# Privatemode.ai service (optional - for confidential models)
privatemode-proxy:
image: ghcr.io/edgelesssys/privatemode/privatemode-proxy:latest
environment:
- PRIVATEMODE_API_KEY=${PRIVATEMODE_API_KEY}
- PRIVATEMODE_CACHE_MODE=${PRIVATEMODE_CACHE_MODE:-none}
- PRIVATEMODE_CACHE_SALT=${PRIVATEMODE_CACHE_SALT:-}
entrypoint: ["/bin/privatemode-proxy"]
command: [
"--apiKey=${PRIVATEMODE_API_KEY}",
"--port=8080"
]
networks:
- enclava-net
restart: unless-stopped
volumes:
enclava-postgres-data:
driver: local
enclava-redis-data:
driver: local
enclava-qdrant-data:
driver: local
nginx-logs:
driver: local
networks:
enclava-net:
driver: bridge

View File

@@ -70,11 +70,8 @@ services:
# Required base URL (derives APP/API/WS URLs)
- BASE_URL=${BASE_URL}
- NEXT_PUBLIC_BASE_URL=${BASE_URL}
# Docker internal ports
- BACKEND_INTERNAL_PORT=${BACKEND_INTERNAL_PORT}
- FRONTEND_INTERNAL_PORT=${FRONTEND_INTERNAL_PORT}
# Internal API URL
- INTERNAL_API_URL=http://enclava-backend:${BACKEND_INTERNAL_PORT}
- INTERNAL_API_URL=http://enclava-backend:8000
depends_on:
- enclava-backend
ports:

View File

@@ -1,5 +1,5 @@
# Use the official Node.js runtime as the base image
FROM node:18-alpine AS base
FROM node:22-alpine AS base
# Install dependencies only when needed
FROM base AS deps
@@ -7,14 +7,10 @@ FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Install dependencies with npm only
COPY package.json package-lock.json ./
RUN npm install
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
@@ -23,23 +19,20 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Environment variables for build
ENV NEXT_TELEMETRY_DISABLED 1
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Build the application
RUN \
if [ -f yarn.lock ]; then yarn build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm build; \
else echo "Lockfile not found." && exit 1; \
fi
# Ensure all dependencies are installed for build
RUN npm install
RUN npm install --save-dev autoprefixer
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Create nextjs user
RUN addgroup --system --gid 1001 nodejs
@@ -48,10 +41,10 @@ RUN adduser --system --uid 1001 nextjs
# Copy node_modules from deps stage
COPY --from=deps /app/node_modules ./node_modules
# Copy built application
# Copy built application (standalone output)
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Set the correct permission for prerender cache
RUN chown -R nextjs:nodejs .next
@@ -61,8 +54,8 @@ USER nextjs
# Expose port
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# Start the application
CMD ["npm", "start"]
# Start the application (standalone)
CMD ["node", "server.js"]

View File

@@ -1,3 +1,10 @@
const path = require('path');
let TsconfigPathsPlugin;
try {
// Optional: only used if installed
TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
} catch (_) {}
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
@@ -9,7 +16,40 @@ const nextConfig = {
typescript: {
ignoreBuildErrors: true,
},
experimental: {
// Enable standalone output for better Docker compatibility
output: 'standalone',
webpack: (config, { dev }) => {
// Ensure resolve object exists
config.resolve = config.resolve || {};
config.resolve.alias = config.resolve.alias || {};
// Hard-set robust alias for "@" => <repo>/src
config.resolve.alias['@'] = path.resolve(__dirname, 'src');
// Ensure common extensions are resolvable
const exts = config.resolve.extensions || [];
config.resolve.extensions = Array.from(new Set([...exts, '.ts', '.tsx', '.js', '.jsx']));
// Add tsconfig-aware resolver plugin if available
if (TsconfigPathsPlugin) {
const existing = config.resolve.plugins || [];
existing.push(
new TsconfigPathsPlugin({
configFile: path.resolve(__dirname, 'tsconfig.json'),
extensions: config.resolve.extensions,
mainFields: ['browser', 'module', 'main'],
})
);
config.resolve.plugins = existing;
}
// Optional: Add debug logging in development
if (dev) {
// eslint-disable-next-line no-console
console.log('Webpack alias config:', config.resolve.alias);
}
return config;
},
env: {
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@
"lint": "next lint"
},
"dependencies": {
"tsconfig-paths-webpack-plugin": "^4.1.0",
"@hookform/resolvers": "^3.3.2",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.0.4",
@@ -30,6 +31,7 @@
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@tailwindcss/typography": "^0.5.16",
"autoprefixer": "^10.4.16",
"axios": "^1.6.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",

View File

@@ -1,6 +1,5 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, Suspense } from "react";
export const dynamic = 'force-dynamic'
import { useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -95,6 +96,7 @@ const PERMISSION_OPTIONS = [
];
function ApiKeysContent() {
const { toast } = useToast();
const searchParams = useSearchParams();
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
@@ -915,3 +917,4 @@ export default function ApiKeysPage() {
</Suspense>
);
}

View File

@@ -1,28 +1,11 @@
import { NextRequest, NextResponse } from 'next/server'
import { proxyRequest, handleProxyResponse } from '@/lib/proxy-auth'
import { apiClient } from '@/lib/api-client'
export async function GET() {
try {
// Direct fetch instead of proxyRequest (proxyRequest had caching issues)
const baseUrl = process.env.INTERNAL_API_URL || `http://enclava-backend:${process.env.BACKEND_INTERNAL_PORT || '8000'}`
const url = `${baseUrl}/api/modules/`
const adminToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsImlzX3N1cGVydXNlciI6dHJ1ZSwicm9sZSI6InN1cGVyX2FkbWluIiwiZXhwIjoxNzg0Nzk2NDI2LjA0NDYxOX0.YOTlUY8nowkaLAXy5EKfnZEpbDgGCabru5R0jdq_DOQ'
// Use the authenticated API client which handles JWT tokens automatically
const data = await apiClient.get('/modules/')
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json'
},
// Disable caching to ensure fresh data
cache: 'no-store'
})
if (!response.ok) {
throw new Error(`Backend responded with ${response.status}: ${response.statusText}`)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
return NextResponse.json(

View File

@@ -1,6 +1,6 @@
"use client"
import { useAuth } from "@/contexts/AuthContext"
import { useAuth } from "@/components/providers/auth-provider"
import { useState, useEffect } from "react"
import { ProtectedRoute } from "@/components/auth/ProtectedRoute"
import { useToast } from "@/hooks/use-toast"

View File

@@ -0,0 +1,379 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ProtectedRoute } from "@/components/auth/ProtectedRoute"
import { apiClient } from "@/lib/api-client"
import { Bug, Database, Search, CheckCircle, XCircle, AlertCircle } from "lucide-react"
interface SystemStatus {
database: string
modules: Record<string, any>
redis: string
qdrant: string
timestamp: string
}
interface ChatbotConfig {
chatbot: {
id: string
name: string
type: string
description: string
created_at: string
is_active: boolean
conversation_count: number
}
prompt_template: {
type: string | null
system_prompt: string | null
variables: any[]
}
rag_collections: any[]
configuration: {
max_tokens: number
temperature: number
streaming: boolean
memory_config: any
}
}
interface RagTestResult {
query: string
results: any[]
collections_searched: string[]
result_count: number
error?: string
message?: string
}
export default function DebugPage() {
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null)
const [chatbots, setChatbots] = useState<any[]>([])
const [selectedChatbot, setSelectedChatbot] = useState<string>("")
const [chatbotConfig, setChatbotConfig] = useState<ChatbotConfig | null>(null)
const [ragQuery, setRagQuery] = useState("What is security?")
const [ragTest, setRagTest] = useState<RagTestResult | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
loadSystemStatus()
loadChatbots()
}, [])
const loadSystemStatus = async () => {
try {
const response = await apiClient.get("/api-internal/v1/debugging/system/status")
setSystemStatus(response)
} catch (error) {
console.error("Failed to load system status:", error)
}
}
const loadChatbots = async () => {
try {
const response = await apiClient.get("/api-internal/v1/chatbot/list")
setChatbots(response)
if (response.length > 0) {
setSelectedChatbot(response[0].id)
}
} catch (error) {
console.error("Failed to load chatbots:", error)
}
}
const loadChatbotConfig = async (chatbotId: string) => {
setLoading(true)
try {
const response = await apiClient.get(`/api-internal/v1/debugging/chatbot/${chatbotId}/config`)
setChatbotConfig(response)
} catch (error) {
console.error("Failed to load chatbot config:", error)
} finally {
setLoading(false)
}
}
const testRagSearch = async () => {
if (!selectedChatbot) return
setLoading(true)
try {
const response = await apiClient.get(
`/api-internal/v1/debugging/chatbot/${selectedChatbot}/test-rag`,
{ params: { query: ragQuery } }
)
setRagTest(response)
} catch (error) {
console.error("Failed to test RAG search:", error)
} finally {
setLoading(false)
}
}
const getStatusIcon = (status: string) => {
if (status.includes("healthy")) return <CheckCircle className="h-4 w-4 text-green-500" />
if (status.includes("error")) return <XCircle className="h-4 w-4 text-red-500" />
return <AlertCircle className="h-4 w-4 text-yellow-500" />
}
return (
<ProtectedRoute>
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Debugging Dashboard</h1>
<p className="text-muted-foreground">
Troubleshoot and diagnose chatbot issues
</p>
</div>
<Tabs defaultValue="system" className="space-y-6">
<TabsList>
<TabsTrigger value="system">System Status</TabsTrigger>
<TabsTrigger value="chatbot">Chatbot Debug</TabsTrigger>
<TabsTrigger value="rag">RAG Testing</TabsTrigger>
</TabsList>
<TabsContent value="system" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
System Health Status
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{systemStatus ? (
<div className="grid gap-4">
<div className="flex items-center justify-between p-4 border rounded">
<span className="font-medium">Database</span>
<div className="flex items-center gap-2">
{getStatusIcon(systemStatus.database)}
<span className="text-sm">{systemStatus.database}</span>
</div>
</div>
<div className="flex items-center justify-between p-4 border rounded">
<span className="font-medium">Redis</span>
<div className="flex items-center gap-2">
{getStatusIcon(systemStatus.redis)}
<span className="text-sm">{systemStatus.redis}</span>
</div>
</div>
<div className="flex items-center justify-between p-4 border rounded">
<span className="font-medium">Qdrant</span>
<div className="flex items-center gap-2">
{getStatusIcon(systemStatus.qdrant)}
<span className="text-sm">{systemStatus.qdrant}</span>
</div>
</div>
<div className="mt-6">
<h4 className="font-medium mb-3">Modules Status</h4>
<div className="grid gap-2">
{Object.entries(systemStatus.modules).map(([name, info]: [string, any]) => (
<div key={name} className="flex items-center justify-between p-3 border rounded">
<span className="text-sm font-medium capitalize">{name}</span>
<div className="flex items-center gap-2">
<Badge variant={info.enabled ? "default" : "secondary"}>
{info.enabled ? "Enabled" : "Disabled"}
</Badge>
<Badge variant={info.status === "healthy" ? "default" : "destructive"}>
{info.status}
</Badge>
</div>
</div>
))}
</div>
</div>
</div>
) : (
<p>Loading system status...</p>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="chatbot" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bug className="h-5 w-5" />
Chatbot Configuration
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-4">
<div className="flex-1">
<label className="text-sm font-medium">Select Chatbot</label>
<select
value={selectedChatbot}
onChange={(e) => {
setSelectedChatbot(e.target.value)
if (e.target.value) {
loadChatbotConfig(e.target.value)
}
}}
className="w-full mt-1 p-2 border rounded"
>
{chatbots.map((bot) => (
<option key={bot.id} value={bot.id}>
{bot.name}
</option>
))}
</select>
</div>
<Button
onClick={() => selectedChatbot && loadChatbotConfig(selectedChatbot)}
disabled={loading || !selectedChatbot}
>
Load Config
</Button>
</div>
{chatbotConfig && (
<div className="space-y-6 mt-6">
<div>
<h4 className="font-medium mb-2">Chatbot Info</h4>
<div className="p-4 border rounded space-y-2 text-sm">
<div><strong>Name:</strong> {chatbotConfig.chatbot.name}</div>
<div><strong>Type:</strong> {chatbotConfig.chatbot.type}</div>
<div><strong>Description:</strong> {chatbotConfig.chatbot.description}</div>
<div><strong>Active:</strong> {chatbotConfig.chatbot.is_active ? "Yes" : "No"}</div>
<div><strong>Conversations:</strong> {chatbotConfig.chatbot.conversation_count}</div>
</div>
</div>
<div>
<h4 className="font-medium mb-2">Configuration</h4>
<div className="p-4 border rounded space-y-2 text-sm">
<div><strong>Max Tokens:</strong> {chatbotConfig.configuration.max_tokens}</div>
<div><strong>Temperature:</strong> {chatbotConfig.configuration.temperature}</div>
<div><strong>Streaming:</strong> {chatbotConfig.configuration.streaming ? "Yes" : "No"}</div>
</div>
</div>
<div>
<h4 className="font-medium mb-2">Prompt Template</h4>
<div className="p-4 border rounded">
<div className="text-sm mb-2">
<strong>Type:</strong> {chatbotConfig.prompt_template.type || "None"}
</div>
{chatbotConfig.prompt_template.system_prompt && (
<div className="mt-3">
<div className="text-sm font-medium mb-1">System Prompt:</div>
<pre className="text-xs bg-muted p-3 rounded overflow-auto max-h-40">
{chatbotConfig.prompt_template.system_prompt}
</pre>
</div>
)}
</div>
</div>
{chatbotConfig.rag_collections.length > 0 && (
<div>
<h4 className="font-medium mb-2">RAG Collections</h4>
<div className="space-y-2">
{chatbotConfig.rag_collections.map((collection) => (
<div key={collection.id} className="p-3 border rounded text-sm">
<div><strong>Name:</strong> {collection.name}</div>
<div><strong>Documents:</strong> {collection.document_count}</div>
<div><strong>Qdrant Collection:</strong> {collection.qdrant_collection_name}</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="rag" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Search className="h-5 w-5" />
RAG Search Test
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-4">
<div className="flex-1">
<label className="text-sm font-medium">Test Query</label>
<Input
value={ragQuery}
onChange={(e) => setRagQuery(e.target.value)}
placeholder="Enter a test query..."
className="mt-1"
/>
</div>
<Button onClick={testRagSearch} disabled={loading || !selectedChatbot}>
Test Search
</Button>
</div>
{ragTest && (
<div className="mt-6 space-y-4">
<div className="p-4 border rounded">
<h4 className="font-medium mb-2">Test Results</h4>
<div className="text-sm space-y-1">
<div><strong>Query:</strong> {ragTest.query}</div>
<div><strong>Results Found:</strong> {ragTest.result_count}</div>
<div><strong>Collections Searched:</strong> {ragTest.collections_searched.join(", ")}</div>
{ragTest.message && (
<div><strong>Message:</strong> {ragTest.message}</div>
)}
{ragTest.error && (
<div className="text-red-500"><strong>Error:</strong> {ragTest.error}</div>
)}
</div>
</div>
{ragTest.results.length > 0 && (
<div>
<h4 className="font-medium mb-2">Search Results</h4>
<div className="space-y-3 max-h-96 overflow-y-auto">
{ragTest.results.map((result, index) => (
<div key={index} className="p-3 border rounded text-sm">
<div className="flex justify-between items-start mb-2">
<Badge variant="outline">Score: {result.score?.toFixed(3) || "N/A"}</Badge>
{result.collection_name && (
<Badge variant="secondary">{result.collection_name}</Badge>
)}
</div>
<div className="text-xs text-muted-foreground mb-1">
{result.metadata?.source || "Unknown source"}
</div>
<div className="text-sm">
{result.content?.substring(0, 200)}
{result.content?.length > 200 && "..."}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
<div className="mt-8 p-4 border rounded">
<h3 className="font-medium mb-2">How to Use This Dashboard</h3>
<ul className="text-sm text-muted-foreground space-y-1">
<li> <strong>System Status:</strong> Check if all services (Database, Redis, Qdrant) are healthy</li>
<li> <strong>Chatbot Debug:</strong> View detailed configuration for any chatbot</li>
<li> <strong>RAG Testing:</strong> Test if document search is working correctly</li>
<li> Check browser console logs for detailed request/response debugging information</li>
</ul>
</div>
</div>
</ProtectedRoute>
)
}

View File

@@ -4,9 +4,10 @@ import './globals.css'
import { ThemeProvider } from '@/components/providers/theme-provider'
import { Toaster } from '@/components/ui/toaster'
import { Toaster as HotToaster } from 'react-hot-toast'
import { AuthProvider } from '@/contexts/AuthContext'
import { AuthProvider } from '@/components/providers/auth-provider'
import { ModulesProvider } from '@/contexts/ModulesContext'
import { PluginProvider } from '@/contexts/PluginContext'
import { ToastProvider } from '@/contexts/ToastContext'
import { Navigation } from '@/components/ui/navigation'
const inter = Inter({ subsets: ['latin'] })
@@ -16,8 +17,21 @@ export const viewport: Viewport = {
initialScale: 1,
}
// Function to determine the base URL with proper protocol
const getBaseUrl = () => {
// In production, we need to detect if we're behind HTTPS
if (typeof window !== 'undefined') {
const protocol = window.location.protocol === 'https:' ? 'https' : 'http'
const host = process.env.NEXT_PUBLIC_BASE_URL || window.location.hostname
return `${protocol}://${host}`
}
// For build time/server side, default to HTTP for dev, HTTPS for production
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'
return `${protocol}://${process.env.NEXT_PUBLIC_BASE_URL || 'localhost'}`
}
export const metadata: Metadata = {
metadataBase: new URL(`http://${process.env.NEXT_PUBLIC_BASE_URL || 'localhost'}`),
metadataBase: new URL(getBaseUrl()),
title: 'Enclava Platform',
description: 'Secure AI processing platform with plugin-based architecture and confidential computing',
keywords: ['AI', 'Enclava', 'Confidential Computing', 'LLM', 'TEE'],
@@ -26,7 +40,7 @@ export const metadata: Metadata = {
openGraph: {
type: 'website',
locale: 'en_US',
url: `http://${process.env.NEXT_PUBLIC_BASE_URL || 'localhost'}`,
url: getBaseUrl(),
title: 'Enclava Platform',
description: 'Secure AI processing platform with plugin-based architecture and confidential computing',
siteName: 'Enclava',
@@ -55,6 +69,7 @@ export default function RootLayout({
<AuthProvider>
<ModulesProvider>
<PluginProvider>
<ToastProvider>
<div className="min-h-screen bg-background">
<Navigation />
<main className="container mx-auto px-4 py-8">
@@ -62,6 +77,7 @@ export default function RootLayout({
</main>
</div>
<Toaster />
</ToastProvider>
<HotToaster />
</PluginProvider>
</ModulesProvider>

View File

@@ -18,7 +18,6 @@ import {
Plus,
Settings,
Trash2,
Copy,
Calendar,
Lock,
Unlock,
@@ -187,15 +186,6 @@ function LLMPageContent() {
}
}
const copyToClipboard = (text: string, type: string = "API key") => {
navigator.clipboard.writeText(text)
toast({
title: "Copied!",
description: `${type} copied to clipboard`
})
}
const formatCurrency = (cents: number) => {
return `$${(cents / 100).toFixed(4)}`
}
@@ -205,21 +195,6 @@ function LLMPageContent() {
return new Date(dateStr).toLocaleDateString()
}
// Get the public API URL from the current window location
const getPublicApiUrl = () => {
if (typeof window !== 'undefined') {
const protocol = window.location.protocol
const hostname = window.location.hostname
const port = window.location.port || (protocol === 'https:' ? '443' : '80')
const portSuffix = (protocol === 'https:' && port === '443') || (protocol === 'http:' && port === '80') ? '' : `:${port}`
return `${protocol}//${hostname}${portSuffix}/api/v1`
}
return 'http://localhost/api/v1'
}
const publicApiUrl = getPublicApiUrl()
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
@@ -229,77 +204,6 @@ function LLMPageContent() {
</p>
</div>
{/* Public API URL Display */}
<Card className="mb-6 border-blue-200 bg-blue-50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-blue-700">
<Settings className="h-5 w-5" />
OpenAI-Compatible API Configuration
</CardTitle>
<CardDescription className="text-blue-600">
Use this endpoint URL to configure external tools like Open WebUI, Continue.dev, or any OpenAI-compatible client.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<Label className="text-sm font-medium text-blue-700">API Base URL</Label>
<div className="mt-1 flex items-center gap-2">
<code className="flex-1 p-3 bg-white border border-blue-200 rounded-md text-sm font-mono">
{publicApiUrl}
</code>
<Button
onClick={() => copyToClipboard(publicApiUrl, "API URL")}
variant="outline"
size="sm"
className="flex items-center gap-1 border-blue-300 text-blue-700 hover:bg-blue-100"
>
<Copy className="h-4 w-4" />
Copy
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div className="space-y-2">
<h4 className="font-medium text-blue-700">Available Endpoints:</h4>
<ul className="space-y-1 text-blue-600">
<li> <code>GET /v1/models</code> - List available models</li>
<li> <code>POST /v1/chat/completions</code> - Chat completions</li>
<li> <code>POST /v1/embeddings</code> - Text embeddings</li>
</ul>
</div>
<div className="space-y-2">
<h4 className="font-medium text-blue-700">Configuration Example:</h4>
<div className="bg-white border border-blue-200 rounded p-2 text-xs font-mono">
<div>Base URL: {publicApiUrl}</div>
<div>API Key: ce_your_api_key</div>
<div>Model: gpt-3.5-turbo</div>
</div>
</div>
</div>
<div className="bg-blue-100 border border-blue-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-blue-600 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-700">
<span className="font-medium">Setup Instructions:</span>
<br />
1. Copy the API Base URL above
<br />
2. Create an API key in the "API Keys" tab below
<br />
3. Use both in your OpenAI-compatible client configuration
<br />
4. Do NOT append additional paths like "/models" - clients handle this automatically
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="api-keys">API Keys</TabsTrigger>

View File

@@ -2,7 +2,7 @@
import { useState } from "react"
import { useRouter } from "next/navigation"
import { useAuth } from "@/contexts/AuthContext"
import { useAuth } from "@/components/providers/auth-provider"
// Force dynamic rendering for authentication
export const dynamic = 'force-dynamic'

View File

@@ -1,6 +1,6 @@
"use client"
import { useAuth } from "@/contexts/AuthContext"
import { useAuth } from "@/components/providers/auth-provider"
import { useRouter } from "next/navigation"
import { useEffect } from "react"

View File

@@ -31,7 +31,7 @@ import { Edit3, RotateCcw, Loader2, Save, AlertTriangle, Plus, Sparkles } from '
import toast from 'react-hot-toast'
import { apiClient } from '@/lib/api-client'
import { config } from '@/lib/config'
import { useAuth } from '@/contexts/AuthContext'
import { useAuth } from '@/components/providers/auth-provider'
interface PromptTemplate {
id: string

View File

@@ -11,7 +11,7 @@ import { Plus, Database, Upload, Search, Trash2, FileText, AlertCircle } from "l
import { CollectionManager } from "@/components/rag/collection-manager"
import { DocumentUpload } from "@/components/rag/document-upload"
import { DocumentBrowser } from "@/components/rag/document-browser"
import { useAuth } from "@/contexts/AuthContext"
import { useAuth } from "@/components/providers/auth-provider"
import { ProtectedRoute } from '@/components/auth/ProtectedRoute'
import { apiClient } from '@/lib/api-client'

View File

@@ -15,7 +15,6 @@ import {
Settings,
Save,
RefreshCw,
Shield,
Globe,
Database,
Mail,
@@ -36,30 +35,9 @@ import { useModules, triggerModuleRefresh } from '@/contexts/ModulesContext';
import { Badge } from '@/components/ui/badge';
interface SystemSettings {
// Security Settings
security: {
password_min_length: number;
password_require_uppercase: boolean;
password_require_lowercase: boolean;
password_require_numbers: boolean;
password_require_symbols: boolean;
session_timeout_minutes: number;
max_login_attempts: number;
lockout_duration_minutes: number;
require_2fa: boolean;
allowed_domains: string[];
};
// API Settings
api: {
// Security Settings
security_enabled: boolean;
threat_detection_enabled: boolean;
rate_limiting_enabled: boolean;
ip_reputation_enabled: boolean;
anomaly_detection_enabled: boolean;
security_headers_enabled: boolean;
// Rate Limiting by Authentication Level
rate_limit_authenticated_per_minute: number;
rate_limit_authenticated_per_hour: number;
@@ -68,22 +46,12 @@ interface SystemSettings {
rate_limit_premium_per_minute: number;
rate_limit_premium_per_hour: number;
// Security Thresholds
security_risk_threshold: number;
security_warning_threshold: number;
anomaly_threshold: number;
// Request Settings
max_request_size_mb: number;
max_request_size_premium_mb: number;
enable_cors: boolean;
cors_origins: string[];
api_key_expiry_days: number;
// IP Security
blocked_ips: string[];
allowed_ips: string[];
csp_header: string;
};
// Notification Settings
@@ -95,7 +63,6 @@ interface SystemSettings {
smtp_use_tls: boolean;
from_address: string;
budget_alerts: boolean;
security_alerts: boolean;
system_alerts: boolean;
};
}
@@ -183,7 +150,10 @@ function SettingsPageContent() {
// Transform each category from backend format {key: {value, type, description}}
// to frontend format {key: value}
// Skip security category as it has been removed from the UI
for (const [categoryName, categorySettings] of Object.entries(data)) {
if (categoryName === 'security') continue; // Skip security settings
if (typeof categorySettings === 'object' && categorySettings !== null) {
transformedSettings[categoryName as keyof SystemSettings] = {} as any;
@@ -384,213 +354,26 @@ function SettingsPageContent() {
</Alert>
)}
<Tabs defaultValue="security" className="space-y-6">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="security">Security</TabsTrigger>
<Tabs defaultValue="api" className="space-y-6">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="api">API</TabsTrigger>
<TabsTrigger value="notifications">Notifications</TabsTrigger>
<TabsTrigger value="modules">Modules</TabsTrigger>
</TabsList>
<TabsContent value="security" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Shield className="mr-2 h-5 w-5" />
Security Settings
</CardTitle>
<CardDescription>
Configure password policies, session management, and authentication settings
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<h3 className="text-lg font-medium">Password Policy</h3>
<div className="space-y-3">
<div>
<Label htmlFor="password-min-length">Minimum Password Length</Label>
<Input
id="password-min-length"
type="number"
min="6"
max="50"
value={settings.security.password_min_length}
onChange={(e) => updateSetting("security", "password_min_length", parseInt(e.target.value))}
/>
</div>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Switch
checked={settings.security.password_require_uppercase}
onCheckedChange={(checked) => updateSetting("security", "password_require_uppercase", checked)}
/>
<Label>Require uppercase letters</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={settings.security.password_require_lowercase}
onCheckedChange={(checked) => updateSetting("security", "password_require_lowercase", checked)}
/>
<Label>Require lowercase letters</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={settings.security.password_require_numbers}
onCheckedChange={(checked) => updateSetting("security", "password_require_numbers", checked)}
/>
<Label>Require numbers</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={settings.security.password_require_symbols}
onCheckedChange={(checked) => updateSetting("security", "password_require_symbols", checked)}
/>
<Label>Require special characters</Label>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-medium">Session & Authentication</h3>
<div className="space-y-3">
<div>
<Label htmlFor="session-timeout">Session Timeout (minutes)</Label>
<Input
id="session-timeout"
type="number"
min="5"
max="1440"
value={settings.security.session_timeout_minutes}
onChange={(e) => updateSetting("security", "session_timeout_minutes", parseInt(e.target.value))}
/>
</div>
<div>
<Label htmlFor="max-login-attempts">Max Login Attempts</Label>
<Input
id="max-login-attempts"
type="number"
min="3"
max="10"
value={settings.security.max_login_attempts}
onChange={(e) => updateSetting("security", "max_login_attempts", parseInt(e.target.value))}
/>
</div>
<div>
<Label htmlFor="lockout-duration">Lockout Duration (minutes)</Label>
<Input
id="lockout-duration"
type="number"
min="5"
max="60"
value={settings.security.lockout_duration_minutes}
onChange={(e) => updateSetting("security", "lockout_duration_minutes", parseInt(e.target.value))}
/>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={settings.security.require_2fa}
onCheckedChange={(checked) => updateSetting("security", "require_2fa", checked)}
/>
<Label>Require Two-Factor Authentication</Label>
</div>
</div>
</div>
</div>
<div>
<Label htmlFor="allowed-domains">Allowed Email Domains (one per line)</Label>
<Textarea
id="allowed-domains"
value={settings.security.allowed_domains.join('\n')}
onChange={(e) => updateSetting("security", "allowed_domains", e.target.value.split('\n').filter(d => d.trim()))}
placeholder="example.com&#10;company.org"
rows={3}
/>
</div>
<Button
onClick={() => handleSaveSection("security")}
disabled={saving === "security"}
>
<Save className="mr-2 h-4 w-4" />
{saving === "security" ? "Saving..." : "Save Security Settings"}
</Button>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="api" className="space-y-6">
<TabsContent value="api" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Globe className="mr-2 h-5 w-5" />
API & Security Settings
API Settings
</CardTitle>
<CardDescription>
Configure API security, rate limits, threat detection, and request handling
Configure API rate limits and request handling
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Security Features */}
<div className="space-y-4">
<h3 className="text-lg font-medium flex items-center">
<Shield className="mr-2 h-5 w-5" />
Security Features
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Switch
checked={settings.api.security_enabled}
onCheckedChange={(checked) => updateSetting("api", "security_enabled", checked)}
/>
<Label>Enable API Security</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={settings.api.threat_detection_enabled}
onCheckedChange={(checked) => updateSetting("api", "threat_detection_enabled", checked)}
/>
<Label>Threat Detection</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={settings.api.rate_limiting_enabled}
onCheckedChange={(checked) => updateSetting("api", "rate_limiting_enabled", checked)}
/>
<Label>Rate Limiting</Label>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Switch
checked={settings.api.ip_reputation_enabled}
onCheckedChange={(checked) => updateSetting("api", "ip_reputation_enabled", checked)}
/>
<Label>IP Reputation Checking</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={settings.api.anomaly_detection_enabled}
onCheckedChange={(checked) => updateSetting("api", "anomaly_detection_enabled", checked)}
/>
<Label>Anomaly Detection</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={settings.api.security_headers_enabled}
onCheckedChange={(checked) => updateSetting("api", "security_headers_enabled", checked)}
/>
<Label>Security Headers</Label>
</div>
</div>
</div>
</div>
{/* Rate Limiting by Authentication Level */}
{settings.api.rate_limiting_enabled && (
<div className="space-y-4">
<h3 className="text-lg font-medium">Rate Limiting by Authentication Level</h3>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
@@ -677,84 +460,6 @@ function SettingsPageContent() {
</div>
</div>
</div>
)}
{/* Security Thresholds */}
{settings.api.security_enabled && (
<div className="space-y-4">
<h3 className="text-lg font-medium">Security Thresholds</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label htmlFor="risk-threshold">Risk Threshold (Block)</Label>
<Input
id="risk-threshold"
type="number"
min="0"
max="1"
step="0.1"
value={settings.api.security_risk_threshold}
onChange={(e) => updateSetting("api", "security_risk_threshold", parseFloat(e.target.value))}
/>
<p className="text-xs text-muted-foreground mt-1">Requests above this score are blocked</p>
</div>
<div>
<Label htmlFor="warning-threshold">Warning Threshold</Label>
<Input
id="warning-threshold"
type="number"
min="0"
max="1"
step="0.1"
value={settings.api.security_warning_threshold}
onChange={(e) => updateSetting("api", "security_warning_threshold", parseFloat(e.target.value))}
/>
<p className="text-xs text-muted-foreground mt-1">Requests above this score generate warnings</p>
</div>
<div>
<Label htmlFor="anomaly-threshold">Anomaly Threshold</Label>
<Input
id="anomaly-threshold"
type="number"
min="0"
max="1"
step="0.1"
value={settings.api.anomaly_threshold}
onChange={(e) => updateSetting("api", "anomaly_threshold", parseFloat(e.target.value))}
/>
<p className="text-xs text-muted-foreground mt-1">Anomalies above this threshold are flagged</p>
</div>
</div>
</div>
)}
{/* IP Security */}
{settings.api.security_enabled && (
<div className="space-y-4">
<h3 className="text-lg font-medium">IP Security</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label htmlFor="blocked-ips">Blocked IPs (one per line)</Label>
<Textarea
id="blocked-ips"
value={settings.api.blocked_ips.join('\n')}
onChange={(e) => updateSetting("api", "blocked_ips", e.target.value.split('\n').filter(ip => ip.trim()))}
placeholder="192.168.1.100&#10;10.0.0.50"
rows={3}
/>
</div>
<div>
<Label htmlFor="allowed-ips">Allowed IPs (empty = allow all)</Label>
<Textarea
id="allowed-ips"
value={settings.api.allowed_ips.join('\n')}
onChange={(e) => updateSetting("api", "allowed_ips", e.target.value.split('\n').filter(ip => ip.trim()))}
placeholder="192.168.1.0/24&#10;10.0.0.1"
rows={3}
/>
</div>
</div>
</div>
)}
{/* Request Settings */}
<div className="space-y-4">
@@ -823,28 +528,12 @@ function SettingsPageContent() {
</div>
)}
{/* Security Headers */}
{settings.api.security_headers_enabled && (
<div className="space-y-4">
<div>
<Label htmlFor="csp-header">Content Security Policy Header</Label>
<Textarea
id="csp-header"
value={settings.api.csp_header}
onChange={(e) => updateSetting("api", "csp_header", e.target.value)}
placeholder="default-src 'self'; script-src 'self' 'unsafe-inline';"
rows={2}
/>
</div>
</div>
)}
<Button
onClick={() => handleSaveSection("api")}
disabled={saving === "api"}
>
<Save className="mr-2 h-4 w-4" />
{saving === "api" ? "Saving..." : "Save API & Security Settings"}
{saving === "api" ? "Saving..." : "Save API Settings"}
</Button>
</CardContent>
</Card>
@@ -933,13 +622,6 @@ function SettingsPageContent() {
/>
<Label>Budget Alerts</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={settings.notifications.security_alerts}
onCheckedChange={(checked) => updateSetting("notifications", "security_alerts", checked)}
/>
<Label>Security Alerts</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={settings.notifications.system_alerts}

View File

@@ -1,6 +1,6 @@
"use client"
import { useAuth } from "@/contexts/AuthContext"
import { useAuth } from "@/components/providers/auth-provider"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { useState } from "react"
@@ -27,15 +27,17 @@ export default function TestAuthPage() {
const expiry = tokenManager.getTokenExpiry()
const refreshExpiry = tokenManager.getRefreshTokenExpiry()
if (!expiry) return "No token"
if (!expiry.access_token_expiry) return "No token"
const now = new Date()
const timeUntilExpiry = Math.floor((expiry.getTime() - now.getTime()) / 1000)
const accessTimeUntilExpiry = Math.floor((expiry.access_token_expiry - now.getTime() / 1000))
const refreshTimeUntilExpiry = refreshExpiry ? Math.floor((refreshExpiry - now.getTime() / 1000)) : null
return `
Token expires in: ${Math.floor(timeUntilExpiry / 60)} minutes ${timeUntilExpiry % 60} seconds
Access token expiry: ${expiry.toLocaleString()}
Refresh token expiry: ${refreshExpiry?.toLocaleString() || 'N/A'}
Access token expires in: ${Math.floor(accessTimeUntilExpiry / 60)} minutes ${accessTimeUntilExpiry % 60} seconds
Refresh token expires in: ${refreshTimeUntilExpiry ? `${Math.floor(refreshTimeUntilExpiry / 60)} minutes ${refreshTimeUntilExpiry % 60} seconds` : 'N/A'}
Access token expiry: ${new Date(expiry.access_token_expiry * 1000).toLocaleString()}
Refresh token expiry: ${refreshExpiry ? new Date(refreshExpiry * 1000).toLocaleString() : 'N/A'}
Authenticated: ${tokenManager.isAuthenticated()}
`
}

View File

@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { useAuth } from "@/contexts/AuthContext"
import { useAuth } from "@/components/providers/auth-provider"
interface ProtectedRouteProps {
children: React.ReactNode

View File

@@ -11,7 +11,7 @@ import { Separator } from "@/components/ui/separator"
import { MessageCircle, Send, Bot, User, Loader2, Copy, ThumbsUp, ThumbsDown } from "lucide-react"
import { useToast } from "@/hooks/use-toast"
import { generateTimestampId } from "@/lib/id-utils"
import { chatbotApi, type AppError } from "@/lib/api-client"
import { chatbotApi } from "@/lib/api-client"
import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm"
import rehypeHighlight from "rehype-highlight"
@@ -118,6 +118,16 @@ export function ChatInterface({ chatbotId, chatbotName, onClose }: ChatInterface
setInput("")
setIsLoading(true)
// Enhanced logging for debugging
const debugInfo = {
chatbotId,
messageLength: messageToSend.length,
conversationId,
timestamp: new Date().toISOString(),
messagesCount: messages.length
}
console.log('=== CHAT REQUEST DEBUG ===', debugInfo)
try {
let data: any
@@ -135,9 +145,9 @@ export function ChatInterface({ chatbotId, chatbotName, onClose }: ChatInterface
)
const assistantMessage: ChatMessage = {
id: data.message_id || generateTimestampId('msg'),
id: data.id || generateTimestampId('msg'),
role: 'assistant',
content: data.response,
content: data.choices?.[0]?.message?.content || data.response || 'No response',
timestamp: new Date(),
sources: data.sources
}

View File

@@ -28,7 +28,7 @@ import {
AlertCircle
} from 'lucide-react';
import { usePlugin, type PluginInfo, type AvailablePlugin } from '../../contexts/PluginContext';
import { useAuth } from '../../contexts/AuthContext';
import { useAuth } from '@/components/providers/auth-provider';
import { PluginConfigurationDialog } from './PluginConfigurationDialog';
interface PluginCardProps {

View File

@@ -8,7 +8,8 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCircle, Loader2 } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import { useAuth } from '@/components/providers/auth-provider';
import { tokenManager } from '@/lib/token-manager';
import { usePlugin, type PluginInfo } from '../../contexts/PluginContext';
import { config } from '../../lib/config';
@@ -48,8 +49,8 @@ const PluginIframe: React.FC<PluginIframeProps> = ({
// Validate origin - should be from our backend
const allowedOrigins = [
window.location.origin,
config.getBackendUrl(),
config.getApiUrl()
config.API_BASE_URL,
config.API_BASE_URL
].filter(Boolean);
if (!allowedOrigins.some(origin => event.origin.startsWith(origin))) {
@@ -161,7 +162,8 @@ export const PluginPageRenderer: React.FC<PluginPageRendererProps> = ({
pagePath,
componentName
}) => {
const { user, token } = useAuth();
const { user } = useAuth();
const token = tokenManager.getAccessToken();
const {
installedPlugins,
getPluginPages,

View File

@@ -3,11 +3,13 @@
import * as React from "react"
import { createContext, useContext, useEffect, useState } from "react"
import { apiClient } from "@/lib/api-client"
import { tokenManager } from "@/lib/token-manager"
interface User {
id: string
username: string
email: string
name?: string
role: string
permissions: string[]
created_at: string
@@ -17,7 +19,8 @@ interface User {
interface AuthContextType {
user: User | null
isLoading: boolean
login: (username: string, password: string) => Promise<void>
isAuthenticated: boolean
login: (email: string, password: string) => Promise<void>
logout: () => void
register: (username: string, email: string, password: string) => Promise<void>
refreshToken: () => Promise<void>
@@ -39,7 +42,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
// Check for existing token on mount
const token = localStorage.getItem("access_token")
const token = tokenManager.getAccessToken()
if (token) {
// Validate token and get user info
validateToken(token)
@@ -50,33 +53,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const validateToken = async (token: string) => {
try {
// Temporarily set token in localStorage for apiClient to use
const previousToken = localStorage.getItem('token')
localStorage.setItem('token', token)
const userData = await apiClient.get("/api-internal/v1/auth/me")
setUser(userData)
// Restore previous token if different
if (previousToken && previousToken !== token) {
localStorage.setItem('token', previousToken)
}
} catch (error) {
localStorage.removeItem("access_token")
localStorage.removeItem("refresh_token")
tokenManager.clearTokens()
} finally {
setIsLoading(false)
}
}
const login = async (username: string, password: string) => {
const login = async (email: string, password: string) => {
try {
const data = await apiClient.post("/api-internal/v1/auth/login", { username, password })
const data = await apiClient.post("/api-internal/v1/auth/login", { email, password })
// Store tokens
localStorage.setItem("access_token", data.access_token)
localStorage.setItem("refresh_token", data.refresh_token)
localStorage.setItem("token", data.access_token) // Also set token for apiClient
// Store tokens using tokenManager
tokenManager.setTokens(data.access_token, data.refresh_token)
// Get user info
await validateToken(data.access_token)
@@ -89,10 +80,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
try {
const data = await apiClient.post("/api-internal/v1/auth/register", { username, email, password })
// Store tokens
localStorage.setItem("access_token", data.access_token)
localStorage.setItem("refresh_token", data.refresh_token)
localStorage.setItem("token", data.access_token) // Also set token for apiClient
// Store tokens using tokenManager
tokenManager.setTokens(data.access_token, data.refresh_token)
// Get user info
await validateToken(data.access_token)
@@ -102,22 +91,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
const logout = () => {
localStorage.removeItem("access_token")
localStorage.removeItem("refresh_token")
localStorage.removeItem("token") // Also clear token for apiClient
tokenManager.clearTokens()
setUser(null)
}
const refreshToken = async () => {
try {
const refresh_token = localStorage.getItem("refresh_token")
const refresh_token = tokenManager.getRefreshToken()
if (!refresh_token) {
throw new Error("No refresh token available")
}
const data = await apiClient.post("/api-internal/v1/auth/refresh", { refresh_token })
localStorage.setItem("access_token", data.access_token)
localStorage.setItem("token", data.access_token) // Also set token for apiClient
tokenManager.setTokens(data.access_token, refresh_token)
return data.access_token
} catch (error) {
@@ -130,6 +116,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const value: AuthContextType = {
user,
isLoading,
isAuthenticated: !!user,
login,
logout,
register,

View File

@@ -91,8 +91,9 @@ export function DocumentUpload({ collections, selectedCollection, onDocumentUplo
updateProgress(60)
await uploadFile(
'/api-internal/v1/rag/documents',
uploadingFile.file,
'/api-internal/v1/rag/documents',
(progress) => updateProgress(progress),
{ collection_id: targetCollection }
)

View File

@@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { ThemeToggle } from "@/components/ui/theme-toggle"
import { UserMenu } from "@/components/ui/user-menu"
import { useAuth } from "@/contexts/AuthContext"
import { useAuth } from "@/components/providers/auth-provider"
import { useModules } from "@/contexts/ModulesContext"
import { usePlugin } from "@/contexts/PluginContext"
import {

View File

@@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { useAuth } from "@/contexts/AuthContext"
import { useAuth } from "@/components/providers/auth-provider"
import { useToast } from "@/hooks/use-toast"
import {
DropdownMenu,
@@ -18,6 +18,16 @@ import {
import { User, Settings, Lock, LogOut, ChevronDown } from "lucide-react"
import { useState } from "react"
// Helper function to get API URL with proper protocol
const getApiUrl = () => {
if (typeof window !== 'undefined') {
const protocol = window.location.protocol.slice(0, -1) // Remove ':' from 'https:'
const host = window.location.hostname
return `${protocol}://${host}`
}
return `http://${process.env.NEXT_PUBLIC_BASE_URL || 'localhost'}`
}
export function UserMenu() {
const { user, logout } = useAuth()
const { toast } = useToast()
@@ -62,7 +72,7 @@ export function UserMenu() {
throw new Error('Authentication required')
}
const response = await fetch('/api-internal/v1/auth/change-password', {
const response = await fetch(`${getApiUrl()}/api-internal/v1/auth/change-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -1,179 +0,0 @@
"use client"
import { createContext, useContext, useState, useEffect, ReactNode } from "react"
import { useRouter } from "next/navigation"
import { tokenManager } from "@/lib/token-manager"
interface User {
id: string
email: string
name: string
role: string
}
interface AuthContextType {
user: User | null
isAuthenticated: boolean
login: (email: string, password: string) => Promise<void>
logout: () => void
isLoading: boolean
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
const router = useRouter()
// Initialize auth state and listen to token manager events
useEffect(() => {
const initAuth = async () => {
// Check if we have valid tokens
if (tokenManager.isAuthenticated()) {
// Try to get user info
await fetchUserInfo()
}
setIsLoading(false)
}
// Set up event listeners
const handleTokensUpdated = () => {
// Tokens were updated (refreshed), update user if needed
if (!user) {
fetchUserInfo()
}
}
const handleTokensCleared = () => {
// Tokens were cleared, clear user
setUser(null)
}
const handleSessionExpired = (reason: string) => {
console.log('Session expired:', reason)
setUser(null)
// TokenManager and API client will handle redirect
}
const handleLogout = () => {
setUser(null)
router.push('/login')
}
// Register event listeners
tokenManager.on('tokensUpdated', handleTokensUpdated)
tokenManager.on('tokensCleared', handleTokensCleared)
tokenManager.on('sessionExpired', handleSessionExpired)
tokenManager.on('logout', handleLogout)
// Initialize
initAuth()
// Cleanup
return () => {
tokenManager.off('tokensUpdated', handleTokensUpdated)
tokenManager.off('tokensCleared', handleTokensCleared)
tokenManager.off('sessionExpired', handleSessionExpired)
tokenManager.off('logout', handleLogout)
}
}, [])
const fetchUserInfo = async () => {
try {
const token = await tokenManager.getAccessToken()
if (!token) return
const response = await fetch('/api-internal/v1/auth/me', {
headers: {
'Authorization': `Bearer ${token}`,
},
})
if (response.ok) {
const userData = await response.json()
const user = {
id: userData.id || userData.sub,
email: userData.email,
name: userData.name || userData.email,
role: userData.role || 'user',
}
setUser(user)
// Store user info for offline access
if (typeof window !== 'undefined') {
localStorage.setItem('user', JSON.stringify(user))
}
}
} catch (error) {
console.error('Failed to fetch user info:', error)
}
}
const login = async (email: string, password: string) => {
setIsLoading(true)
try {
const response = await fetch('/api-internal/v1/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Invalid credentials')
}
const data = await response.json()
// Store tokens in TokenManager
tokenManager.setTokens(
data.access_token,
data.refresh_token,
data.expires_in
)
// Fetch user info
await fetchUserInfo()
// Navigate to dashboard
router.push('/dashboard')
} catch (error) {
console.error('Login error:', error)
throw error
} finally {
setIsLoading(false)
}
}
const logout = () => {
tokenManager.logout()
// Token manager will emit 'logout' event which we handle above
}
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: tokenManager.isAuthenticated(),
login,
logout,
isLoading
}}
>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error("useAuth must be used within an AuthProvider")
}
return context
}

View File

@@ -69,8 +69,15 @@ export function ModulesProvider({ children }: { children: ReactNode }) {
setLastUpdated(new Date())
} catch (err) {
// If we get a 401 error, clear the tokens
if (err && typeof err === 'object' && 'response' in err && (err.response as any)?.status === 401) {
tokenManager.clearTokens()
setModules([])
setEnabledModules(new Set())
setError(null)
setLastUpdated(null)
} else if (tokenManager.isAuthenticated()) {
// Only set error if we're authenticated (to avoid noise on auth pages)
if (tokenManager.isAuthenticated()) {
setError(err instanceof Error ? err.message : "Failed to load modules")
}
} finally {

View File

@@ -4,7 +4,7 @@
* Plugin Context - Manages plugin state and UI integration
*/
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
import { useAuth } from './AuthContext';
import { useAuth } from '@/components/providers/auth-provider';
import { apiClient } from '@/lib/api-client';
export interface PluginInfo {

View File

@@ -0,0 +1,150 @@
"use client"
import React, { createContext, useContext, useState, useCallback, useRef } from 'react'
import { generateShortId } from '@/lib/id-utils'
export interface ToastProps {
id: string
title?: string
description?: string
variant?: 'default' | 'destructive' | 'success' | 'warning'
action?: React.ReactElement
duration?: number
}
export interface ToastOptions extends Omit<ToastProps, 'id'> {
duration?: number
}
interface ToastContextType {
toasts: ToastProps[]
toast: (options: ToastOptions) => () => void
success: (title: string, description?: string, options?: Partial<ToastOptions>) => () => void
error: (title: string, description?: string, options?: Partial<ToastOptions>) => () => void
warning: (title: string, description?: string, options?: Partial<ToastOptions>) => () => void
info: (title: string, description?: string, options?: Partial<ToastOptions>) => () => void
dismiss: (id: string) => void
clearAll: () => void
}
const ToastContext = createContext<ToastContextType | undefined>(undefined)
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastProps[]>([])
const timeoutRefs = useRef<Map<string, NodeJS.Timeout>>(new Map())
const dismissToast = useCallback((id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id))
// Clear timeout if exists
const timeoutId = timeoutRefs.current.get(id)
if (timeoutId) {
clearTimeout(timeoutId)
timeoutRefs.current.delete(id)
}
}, [])
const toast = useCallback((options: ToastOptions) => {
const {
duration = 5000,
variant = 'default',
...props
} = options
// Generate unique ID using improved utility
const id = generateShortId('toast')
const toastWithId: ToastProps = {
...props,
id,
variant,
duration
}
// Add to toasts array
setToasts(prev => [...prev, toastWithId])
// Auto-remove after specified duration
if (duration > 0) {
const timeoutId = setTimeout(() => {
dismissToast(id)
}, duration)
timeoutRefs.current.set(id, timeoutId)
}
// Return dismiss function for manual control
return () => dismissToast(id)
}, [dismissToast])
// Convenience methods for common toast types
const success = useCallback((title: string, description?: string, options?: Partial<ToastOptions>) => {
return toast({
title,
description,
variant: 'success',
...options
})
}, [toast])
const error = useCallback((title: string, description?: string, options?: Partial<ToastOptions>) => {
return toast({
title,
description,
variant: 'destructive',
duration: 7000, // Errors should stay longer
...options
})
}, [toast])
const warning = useCallback((title: string, description?: string, options?: Partial<ToastOptions>) => {
return toast({
title,
description,
variant: 'warning',
...options
})
}, [toast])
const info = useCallback((title: string, description?: string, options?: Partial<ToastOptions>) => {
return toast({
title,
description,
variant: 'default',
...options
})
}, [toast])
// Clear all toasts
const clearAll = useCallback(() => {
// Clear all timeouts
timeoutRefs.current.forEach(timeoutId => clearTimeout(timeoutId))
timeoutRefs.current.clear()
setToasts([])
}, [])
const value: ToastContextType = {
toasts,
toast,
success,
error,
warning,
info,
dismiss: dismissToast,
clearAll,
}
return (
<ToastContext.Provider value={value}>
{children}
</ToastContext.Provider>
)
}
export function useToast() {
const context = useContext(ToastContext)
if (context === undefined) {
throw new Error('useToast must be used within a ToastProvider')
}
return context
}

View File

@@ -4,7 +4,7 @@
import { useState, useCallback, useMemo } from 'react'
import { generateId } from '@/lib/id-utils'
import { chatbotApi, type AppError } from '@/lib/api-client'
import { chatbotApi } from '@/lib/api-client'
import { useToast } from './use-toast'
export interface ChatbotConfig {
@@ -59,10 +59,10 @@ export function useChatbotForm() {
const loadChatbots = useCallback(async () => {
setIsLoading(true)
try {
const data = await chatbotApi.listChatbots()
const data = await chatbotApi.list()
setChatbots(data)
} catch (error) {
const appError = error as AppError
console.error('Load chatbots error:', error)
toast.error("Loading Failed", "Failed to load chatbots")
} finally {
setIsLoading(false)
@@ -73,15 +73,20 @@ export function useChatbotForm() {
const createChatbot = useCallback(async (config: ChatbotConfig) => {
setIsSubmitting(true)
try {
const newChatbot = await chatbotApi.createChatbot(config)
const newChatbot = await chatbotApi.create(config)
setChatbots(prev => [...prev, newChatbot])
toast.success("Success", `Chatbot "${config.name}" created successfully`)
return newChatbot
} catch (error) {
const appError = error as AppError
console.error('Create chatbot error:', error)
if (appError.code === 'VALIDATION_ERROR') {
toast.error("Validation Error", appError.details || "Please check your input")
if (error && typeof error === 'object' && 'response' in error) {
const detail = error.response?.data?.detail || error.response?.data?.error
if (detail) {
toast.error("Validation Error", detail)
} else {
toast.error("Creation Failed", "Failed to create chatbot")
}
} else {
toast.error("Creation Failed", "Failed to create chatbot")
}
@@ -95,12 +100,12 @@ export function useChatbotForm() {
const updateChatbot = useCallback(async (id: string, config: ChatbotConfig) => {
setIsSubmitting(true)
try {
const updatedChatbot = await chatbotApi.updateChatbot(id, config)
const updatedChatbot = await chatbotApi.update(id, config)
setChatbots(prev => prev.map(bot => bot.id === id ? updatedChatbot : bot))
toast.success("Success", `Chatbot "${config.name}" updated successfully`)
return updatedChatbot
} catch (error) {
const appError = error as AppError
console.error('Update chatbot error:', error)
toast.error("Update Failed", "Failed to update chatbot")
throw error
} finally {
@@ -112,11 +117,11 @@ export function useChatbotForm() {
const deleteChatbot = useCallback(async (id: string) => {
setIsSubmitting(true)
try {
await chatbotApi.deleteChatbot(id)
await chatbotApi.delete(id)
setChatbots(prev => prev.filter(bot => bot.id !== id))
toast.success("Success", "Chatbot deleted successfully")
} catch (error) {
const appError = error as AppError
console.error('Delete chatbot error:', error)
toast.error("Deletion Failed", "Failed to delete chatbot")
throw error
} finally {

View File

@@ -1,3 +1,4 @@
export interface AppError extends Error {
code: 'UNAUTHORIZED' | 'NETWORK_ERROR' | 'VALIDATION_ERROR' | 'NOT_FOUND' | 'FORBIDDEN' | 'TIMEOUT' | 'UNKNOWN'
status?: number

View File

@@ -12,4 +12,3 @@ export const config = {
return process.env.NEXT_PUBLIC_APP_NAME || 'Enclava'
},
}

View File

@@ -0,0 +1,174 @@
/**
* Utility functions for error handling and user feedback
*/
export interface AppError {
code: string
message: string
details?: string
retryable?: boolean
}
export const ERROR_CODES = {
NETWORK_ERROR: 'NETWORK_ERROR',
UNAUTHORIZED: 'UNAUTHORIZED',
VALIDATION_ERROR: 'VALIDATION_ERROR',
TIMEOUT_ERROR: 'TIMEOUT_ERROR',
SERVER_ERROR: 'SERVER_ERROR',
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
} as const
/**
* Converts various error types into standardized AppError format
*/
export function normalizeError(error: unknown): AppError {
if (error instanceof Error) {
// Network or fetch errors
if (error.name === 'TypeError' && error.message.includes('fetch')) {
return {
code: ERROR_CODES.NETWORK_ERROR,
message: 'Unable to connect to server. Please check your internet connection.',
retryable: true
}
}
// Timeout errors
if (error.name === 'AbortError' || error.message.includes('timeout')) {
return {
code: ERROR_CODES.TIMEOUT_ERROR,
message: 'Request timed out. Please try again.',
retryable: true
}
}
return {
code: ERROR_CODES.UNKNOWN_ERROR,
message: error.message || 'An unexpected error occurred',
details: error.stack,
retryable: false
}
}
if (typeof error === 'string') {
return {
code: ERROR_CODES.UNKNOWN_ERROR,
message: error,
retryable: false
}
}
return {
code: ERROR_CODES.UNKNOWN_ERROR,
message: 'An unknown error occurred',
retryable: false
}
}
/**
* Handles HTTP response errors
*/
export async function handleHttpError(response: Response): Promise<AppError> {
let errorDetails: string
try {
const errorData = await response.json()
errorDetails = errorData.error || errorData.message || 'Unknown error'
} catch {
try {
// Use the cloned response for text reading since original body was consumed
const responseClone = response.clone()
errorDetails = await responseClone.text()
} catch {
errorDetails = `HTTP ${response.status} error`
}
}
switch (response.status) {
case 401:
return {
code: ERROR_CODES.UNAUTHORIZED,
message: 'You need to log in to continue',
details: errorDetails,
retryable: false
}
case 400:
return {
code: ERROR_CODES.VALIDATION_ERROR,
message: 'Invalid request. Please check your input.',
details: errorDetails,
retryable: false
}
case 429:
return {
code: ERROR_CODES.SERVER_ERROR,
message: 'Too many requests. Please wait a moment and try again.',
details: errorDetails,
retryable: true
}
case 500:
case 502:
case 503:
case 504:
return {
code: ERROR_CODES.SERVER_ERROR,
message: 'Server error. Please try again in a moment.',
details: errorDetails,
retryable: true
}
default:
return {
code: ERROR_CODES.SERVER_ERROR,
message: `Request failed (${response.status}): ${errorDetails}`,
details: errorDetails,
retryable: response.status >= 500
}
}
}
/**
* Retry wrapper with exponential backoff
*/
export async function withRetry<T>(
fn: () => Promise<T>,
options: {
maxAttempts?: number
initialDelay?: number
maxDelay?: number
backoffMultiplier?: number
} = {}
): Promise<T> {
const {
maxAttempts = 3,
initialDelay = 1000,
maxDelay = 10000,
backoffMultiplier = 2
} = options
let lastError: unknown
let delay = initialDelay
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn()
} catch (error) {
lastError = error
const appError = normalizeError(error)
// Don't retry non-retryable errors
if (!appError.retryable || attempt === maxAttempts) {
throw error
}
// Wait before retrying
await new Promise(resolve => setTimeout(resolve, delay))
delay = Math.min(delay * backoffMultiplier, maxDelay)
}
}
throw lastError
}

View File

@@ -13,4 +13,3 @@ export function generateTimestampId(prefix = "id"): string {
const rand = Math.floor(Math.random() * 1000).toString().padStart(3, '0')
return `${prefix}_${ts}_${rand}`
}

View File

@@ -0,0 +1,301 @@
/**
* Performance monitoring and optimization utilities
*/
import React from 'react'
export interface PerformanceMetric {
name: string
value: number
timestamp: number
metadata?: Record<string, any>
}
export interface PerformanceReport {
metrics: PerformanceMetric[]
summary: {
averageResponseTime: number
totalRequests: number
errorRate: number
slowestRequests: PerformanceMetric[]
}
}
class PerformanceMonitor {
private metrics: PerformanceMetric[] = []
private maxMetrics = 1000 // Keep last 1000 metrics
private enabled = process.env.NODE_ENV === 'development'
/**
* Start timing an operation
*/
startTiming(name: string, metadata?: Record<string, any>): () => void {
if (!this.enabled) {
return () => {} // No-op in production
}
const startTime = performance.now()
return () => {
const duration = performance.now() - startTime
this.recordMetric(name, duration, metadata)
}
}
/**
* Record a performance metric
*/
recordMetric(name: string, value: number, metadata?: Record<string, any>): void {
if (!this.enabled) return
const metric: PerformanceMetric = {
name,
value,
timestamp: Date.now(),
metadata
}
this.metrics.push(metric)
// Keep only the most recent metrics
if (this.metrics.length > this.maxMetrics) {
this.metrics = this.metrics.slice(-this.maxMetrics)
}
// Log slow operations
if (value > 1000) { // Slower than 1 second
}
}
/**
* Measure and track API calls
*/
async trackApiCall<T>(
name: string,
apiCall: () => Promise<T>,
metadata?: Record<string, any>
): Promise<T> {
const endTiming = this.startTiming(`api_${name}`, metadata)
try {
const result = await apiCall()
endTiming()
return result
} catch (error) {
endTiming()
this.recordMetric(`api_${name}_error`, 1, {
...metadata,
error: error instanceof Error ? error.message : 'Unknown error'
})
throw error
}
}
/**
* Track React component render times
*/
trackComponentRender(componentName: string, renderCount: number = 1): void {
this.recordMetric(`render_${componentName}`, renderCount)
}
/**
* Get performance report
*/
getReport(): PerformanceReport {
const apiMetrics = this.metrics.filter(m => m.name.startsWith('api_'))
const errorMetrics = this.metrics.filter(m => m.name.includes('_error'))
const totalRequests = apiMetrics.length
const errorRate = totalRequests > 0 ? (errorMetrics.length / totalRequests) * 100 : 0
const responseTimes = apiMetrics.map(m => m.value)
const averageResponseTime = responseTimes.length > 0
? responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
: 0
const slowestRequests = [...apiMetrics]
.sort((a, b) => b.value - a.value)
.slice(0, 10)
return {
metrics: this.metrics,
summary: {
averageResponseTime,
totalRequests,
errorRate,
slowestRequests
}
}
}
/**
* Clear all metrics
*/
clear(): void {
this.metrics = []
}
/**
* Enable/disable monitoring
*/
setEnabled(enabled: boolean): void {
this.enabled = enabled
}
/**
* Export metrics for analysis
*/
exportMetrics(): string {
return JSON.stringify({
timestamp: Date.now(),
userAgent: navigator.userAgent,
metrics: this.metrics,
summary: this.getReport().summary
}, null, 2)
}
}
// Global performance monitor instance
export const performanceMonitor = new PerformanceMonitor()
/**
* React hook for component performance tracking
*/
export function usePerformanceTracking(componentName: string) {
const [renderCount, setRenderCount] = React.useState(0)
React.useEffect(() => {
const newCount = renderCount + 1
setRenderCount(newCount)
performanceMonitor.trackComponentRender(componentName, newCount)
})
return {
renderCount,
trackOperation: (name: string, metadata?: Record<string, any>) =>
performanceMonitor.startTiming(`${componentName}_${name}`, metadata),
trackApiCall: <T>(name: string, apiCall: () => Promise<T>) =>
performanceMonitor.trackApiCall(`${componentName}_${name}`, apiCall)
}
}
/**
* Debounce utility for performance optimization
*/
export function debounce<Args extends any[]>(
func: (...args: Args) => void,
delay: number
): (...args: Args) => void {
let timeoutId: NodeJS.Timeout | null = null
return (...args: Args) => {
if (timeoutId) {
clearTimeout(timeoutId)
}
timeoutId = setTimeout(() => {
func.apply(null, args)
}, delay)
}
}
/**
* Throttle utility for performance optimization
*/
export function throttle<Args extends any[]>(
func: (...args: Args) => void,
limit: number
): (...args: Args) => void {
let inThrottle = false
return (...args: Args) => {
if (!inThrottle) {
func.apply(null, args)
inThrottle = true
setTimeout(() => inThrottle = false, limit)
}
}
}
/**
* Memoization utility with performance tracking
*/
export function memoizeWithTracking<Args extends any[], Return>(
fn: (...args: Args) => Return,
keyGenerator?: (...args: Args) => string
): (...args: Args) => Return {
const cache = new Map<string, { result: Return; timestamp: number }>()
const cacheTimeout = 5 * 60 * 1000 // 5 minutes
return (...args: Args) => {
const key = keyGenerator ? keyGenerator(...args) : JSON.stringify(args)
const now = Date.now()
// Check cache
const cached = cache.get(key)
if (cached && (now - cached.timestamp) < cacheTimeout) {
performanceMonitor.recordMetric('memoize_hit', 1, { function: fn.name })
return cached.result
}
// Compute result
const endTiming = performanceMonitor.startTiming('memoize_compute', { function: fn.name })
const result = fn(...args)
endTiming()
// Store in cache
cache.set(key, { result, timestamp: now })
performanceMonitor.recordMetric('memoize_miss', 1, { function: fn.name })
// Clean up old entries
if (cache.size > 100) {
const entries = Array.from(cache.entries())
entries
.filter(([, value]) => (now - value.timestamp) > cacheTimeout)
.forEach(([key]) => cache.delete(key))
}
return result
}
}
/**
* Web Vitals tracking
*/
export function trackWebVitals() {
if (typeof window === 'undefined') return
// Track Largest Contentful Paint
if ('PerformanceObserver' in window) {
try {
new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.entryType === 'largest-contentful-paint') {
performanceMonitor.recordMetric('lcp', entry.startTime)
}
if (entry.entryType === 'first-input') {
performanceMonitor.recordMetric('fid', (entry as any).processingStart - entry.startTime)
}
})
}).observe({ entryTypes: ['largest-contentful-paint', 'first-input'] })
} catch (error) {
}
}
// Track Cumulative Layout Shift
if ('PerformanceObserver' in window) {
try {
let clsValue = 0
new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (!(entry as any).hadRecentInput) {
clsValue += (entry as any).value
performanceMonitor.recordMetric('cls', clsValue)
}
})
}).observe({ entryTypes: ['layout-shift'] })
} catch (error) {
}
}
}

View File

@@ -0,0 +1,44 @@
// Centralized playground configuration
export const playgroundConfig = {
// Working models (avoiding rate-limited ones)
availableModels: [
{
id: 'openrouter-gpt-4',
name: 'GPT-4 (OpenRouter)',
provider: 'OpenRouter',
category: 'chat',
status: 'available'
},
{
id: 'openrouter-claude-3-sonnet',
name: 'Claude 3 Sonnet (OpenRouter)',
provider: 'OpenRouter',
category: 'chat',
status: 'available'
}
],
// Rate limited models to avoid
rateLimitedModels: [
'ollama-qwen3-235b',
'ollama-gemini-2.0-flash',
'ollama-gemini-2.5-pro'
],
// Default settings
defaults: {
model: 'openrouter-gpt-4',
temperature: 0.7,
maxTokens: 150,
systemPrompt: 'You are a helpful AI assistant.'
},
// Error handling
errorMessages: {
rateLimited: 'Model is currently rate limited. Please try another model.',
authFailed: 'Authentication failed. Please refresh the page.',
networkError: 'Network error. Please check your connection.'
}
}
export default playgroundConfig

View File

@@ -138,4 +138,3 @@ class TokenManager extends SimpleEmitter {
}
export const tokenManager = new TokenManager()

View File

@@ -0,0 +1,109 @@
/**
* URL utilities for handling HTTP/HTTPS protocol detection
*/
/**
* Get the base URL with proper protocol detection
* This ensures API calls use the same protocol as the page was loaded with
*/
export const getBaseUrl = (): string => {
if (typeof window !== 'undefined') {
// Client-side: detect current protocol
const protocol = window.location.protocol === 'https:' ? 'https' : 'http'
const host = process.env.NEXT_PUBLIC_BASE_URL || window.location.hostname
return `${protocol}://${host}`
}
// Server-side: default based on environment
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'
return `${protocol}://${process.env.NEXT_PUBLIC_BASE_URL || 'localhost'}`
}
/**
* Get the API URL with proper protocol detection
* This is the main function that should be used for all API calls
*/
export const getApiUrl = (): string => {
if (typeof window !== 'undefined') {
// Client-side: use the same protocol as the current page
const protocol = window.location.protocol.slice(0, -1) // Remove ':' from 'https:'
const host = window.location.hostname
return `${protocol}://${host}`
}
// Server-side: default to HTTP for internal requests
return `http://${process.env.NEXT_PUBLIC_BASE_URL || 'localhost'}`
}
/**
* Get the internal API URL for authenticated endpoints
* This ensures internal API calls use the same protocol as the page
*/
export const getInternalApiUrl = (): string => {
const baseUrl = getApiUrl()
return `${baseUrl}/api-internal`
}
/**
* Get the public API URL for external client endpoints
* This ensures public API calls use the same protocol as the page
*/
export const getPublicApiUrl = (): string => {
const baseUrl = getApiUrl()
return `${baseUrl}/api`
}
/**
* Helper function to make API calls with proper protocol
*/
export const apiFetch = async (
endpoint: string,
options: RequestInit = {}
): Promise<Response> => {
const baseUrl = getApiUrl()
const url = `${baseUrl}${endpoint}`
return fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
})
}
/**
* Helper function for internal API calls
*/
export const internalApiFetch = async (
endpoint: string,
options: RequestInit = {}
): Promise<Response> => {
const url = `${getInternalApiUrl()}${endpoint}`
return fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
})
}
/**
* Helper function for public API calls
*/
export const publicApiFetch = async (
endpoint: string,
options: RequestInit = {}
): Promise<Response> => {
const url = `${getPublicApiUrl()}${endpoint}`
return fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
})
}

View File

@@ -2,7 +2,7 @@ import { type ClassValue } from 'clsx'
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,289 @@
/**
* Validation utilities with TypeScript support
*/
import type { ValidationRule, ValidationRules, ValidationResult } from '@/types/chatbot'
/**
* Validates a single field against its rules
*/
export function validateField<T>(
value: T,
rules: ValidationRule<T> = {}
): string | null {
const {
required = false,
minLength,
maxLength,
min,
max,
pattern,
custom
} = rules
// Required validation
if (required) {
if (value === null || value === undefined) {
return 'This field is required'
}
if (typeof value === 'string' && value.trim().length === 0) {
return 'This field is required'
}
if (Array.isArray(value) && value.length === 0) {
return 'This field is required'
}
}
// Skip other validations if value is empty and not required
if (!required && (value === null || value === undefined || value === '')) {
return null
}
// String length validation
if (typeof value === 'string') {
if (minLength !== undefined && value.length < minLength) {
return `Must be at least ${minLength} characters`
}
if (maxLength !== undefined && value.length > maxLength) {
return `Must be no more than ${maxLength} characters`
}
}
// Number range validation
if (typeof value === 'number') {
if (min !== undefined && value < min) {
return `Must be at least ${min}`
}
if (max !== undefined && value > max) {
return `Must be no more than ${max}`
}
}
// Array length validation
if (Array.isArray(value)) {
if (minLength !== undefined && value.length < minLength) {
return `Must have at least ${minLength} items`
}
if (maxLength !== undefined && value.length > maxLength) {
return `Must have no more than ${maxLength} items`
}
}
// Pattern validation
if (typeof value === 'string' && pattern) {
if (!pattern.test(value)) {
return 'Invalid format'
}
}
// Custom validation
if (custom) {
return custom(value)
}
return null
}
/**
* Validates an entire object against validation rules
*/
export function validateObject<T extends Record<string, any>>(
obj: T,
rules: ValidationRules<T>
): ValidationResult {
const errors: Record<string, string> = {}
for (const [key, rule] of Object.entries(rules)) {
if (rule && key in obj) {
const error = validateField(obj[key], rule as ValidationRule<any>)
if (error) {
errors[key] = error
}
}
}
return {
isValid: Object.keys(errors).length === 0,
errors
}
}
/**
* Common validation rules for chatbot fields
*/
export const chatbotValidationRules = {
name: {
required: true,
minLength: 1,
maxLength: 100,
custom: (value: string) => {
if (!/^[a-zA-Z0-9\s\-_]+$/.test(value)) {
return 'Name can only contain letters, numbers, spaces, hyphens, and underscores'
}
return null
}
},
model: {
required: true,
minLength: 1,
maxLength: 100
},
system_prompt: {
maxLength: 4000,
custom: (value: string) => {
if (value && value.trim().length === 0) {
return 'System prompt cannot be only whitespace'
}
return null
}
},
temperature: {
required: true,
min: 0,
max: 2
},
max_tokens: {
required: true,
min: 1,
max: 4000
},
memory_length: {
required: true,
min: 1,
max: 50
},
rag_top_k: {
required: true,
min: 1,
max: 20
},
fallback_responses: {
minLength: 1,
maxLength: 10,
custom: (responses: string[]) => {
if (responses.some(r => !r || r.trim().length === 0)) {
return 'All fallback responses must be non-empty'
}
return null
}
}
} as const
/**
* Email validation rule
*/
export const emailRule: ValidationRule<string> = {
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
custom: (value: string) => {
if (value && !emailRule.pattern?.test(value)) {
return 'Please enter a valid email address'
}
return null
}
}
/**
* URL validation rule
*/
export const urlRule: ValidationRule<string> = {
pattern: /^https?:\/\/.+/,
custom: (value: string) => {
if (value && !urlRule.pattern?.test(value)) {
return 'Please enter a valid URL starting with http:// or https://'
}
return null
}
}
/**
* Username validation rule
*/
export const usernameRule: ValidationRule<string> = {
minLength: 3,
maxLength: 30,
pattern: /^[a-zA-Z0-9_-]+$/,
custom: (value: string) => {
if (value && !usernameRule.pattern?.test(value)) {
return 'Username can only contain letters, numbers, hyphens, and underscores'
}
return null
}
}
/**
* Password validation rule
*/
export const passwordRule: ValidationRule<string> = {
minLength: 8,
maxLength: 128,
custom: (value: string) => {
if (!value) return null
if (!/(?=.*[a-z])/.test(value)) {
return 'Password must contain at least one lowercase letter'
}
if (!/(?=.*[A-Z])/.test(value)) {
return 'Password must contain at least one uppercase letter'
}
if (!/(?=.*\d)/.test(value)) {
return 'Password must contain at least one number'
}
if (!/(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\?])/.test(value)) {
return 'Password must contain at least one special character'
}
return null
}
}
/**
* Utility to create conditional validation rules
*/
export function when<T>(
condition: (obj: any) => boolean,
rules: ValidationRule<T>
): ValidationRule<T> {
return {
...rules,
custom: (value: T, obj?: any) => {
if (!condition(obj)) {
return null
}
const originalCustom = rules.custom
if (originalCustom) {
return originalCustom(value, obj)
}
return validateField(value, { ...rules, custom: undefined })
}
}
}
/**
* Debounced validation for real-time form validation
*/
export function createDebouncedValidator<T extends Record<string, any>>(
rules: ValidationRules<T>,
delay: number = 300
) {
let timeoutId: NodeJS.Timeout | null = null
return (obj: T, callback: (result: ValidationResult) => void) => {
if (timeoutId) {
clearTimeout(timeoutId)
}
timeoutId = setTimeout(() => {
const result = validateObject(obj, rules)
callback(result)
}, delay)
}
}

File diff suppressed because one or more lines are too long

View File

@@ -13,6 +13,7 @@ http {
proxy_read_timeout 600;
send_timeout 600;
upstream backend {
server enclava-backend:8000;
}

View File

@@ -7,6 +7,9 @@ http {
server enclava-backend-test:8000;
}
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
# Frontend service disabled for simplified testing
# Logging configuration for tests
@@ -41,10 +44,6 @@ http {
proxy_buffering off;
proxy_request_buffering off;
# Timeouts for long-running requests
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# CORS headers for frontend
add_header 'Access-Control-Allow-Origin' '*' always;
@@ -77,9 +76,7 @@ http {
proxy_request_buffering off;
# Timeouts for long-running requests (LLM streaming)
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
# CORS headers for external clients
add_header 'Access-Control-Allow-Origin' '*' always;

View File

@@ -1,90 +0,0 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# path to migration scripts
script_location = migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be installed by running "pip install alembic[tz]"
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses
# os.pathsep. If this key is omitted entirely, it falls back to the legacy
# behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -1,719 +0,0 @@
"""
Zammad Plugin Implementation
Provides integration between Enclava platform and Zammad helpdesk system
"""
from typing import Dict, Any, List, Optional
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks
from pydantic import BaseModel
import aiohttp
import asyncio
from datetime import datetime, timezone
from app.services.base_plugin import BasePlugin, PluginContext
from app.services.plugin_database import PluginDatabaseSession, plugin_db_manager
from app.services.plugin_security import plugin_security_policy_manager
from sqlalchemy import Column, String, DateTime, Text, Boolean, Integer, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID
import uuid
class ZammadTicket(BaseModel):
"""Zammad ticket model"""
id: str
title: str
body: str
status: str
priority: str
customer_id: str
group_id: str
created_at: datetime
updated_at: datetime
ai_summary: Optional[str] = None
class ZammadConfiguration(BaseModel):
"""Zammad configuration model"""
name: str
zammad_url: str
api_token: str
chatbot_id: str
ai_summarization: Dict[str, Any]
sync_settings: Dict[str, Any]
webhook_settings: Dict[str, Any]
# Plugin database models
Base = declarative_base()
class ZammadConfiguration(Base):
__tablename__ = "zammad_configurations"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(String, nullable=False, index=True)
name = Column(String(100), nullable=False)
zammad_url = Column(String(500), nullable=False)
api_token_encrypted = Column(Text, nullable=False)
chatbot_id = Column(String(100), nullable=False)
is_active = Column(Boolean, default=True)
ai_summarization_enabled = Column(Boolean, default=True)
auto_summarize = Column(Boolean, default=True)
sync_enabled = Column(Boolean, default=True)
sync_interval_hours = Column(Integer, default=2)
created_at = Column(DateTime, default=datetime.now(timezone.utc))
updated_at = Column(DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
class ZammadTicket(Base):
__tablename__ = "zammad_tickets"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
zammad_ticket_id = Column(String(50), nullable=False, index=True)
configuration_id = Column(UUID(as_uuid=True), ForeignKey("zammad_configurations.id"))
title = Column(String(500), nullable=False)
body = Column(Text)
status = Column(String(50))
priority = Column(String(50))
customer_id = Column(String(50))
group_id = Column(String(50))
ai_summary = Column(Text)
last_synced = Column(DateTime, default=datetime.now(timezone.utc))
created_at = Column(DateTime, default=datetime.now(timezone.utc))
updated_at = Column(DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
configuration = relationship("ZammadConfiguration", back_populates="tickets")
ZammadConfiguration.tickets = relationship("ZammadTicket", back_populates="configuration")
class ZammadPlugin(BasePlugin):
"""Zammad helpdesk integration plugin with full framework integration"""
def __init__(self, manifest, plugin_token: str):
super().__init__(manifest, plugin_token)
self.zammad_client = None
self.db_models = [ZammadConfiguration, ZammadTicket]
async def initialize(self) -> bool:
"""Initialize Zammad plugin with database setup"""
try:
self.logger.info("Initializing Zammad plugin")
# Create database tables
await self._create_database_tables()
# Test platform API connectivity
health = await self.api_client.get("/health")
self.logger.info(f"Platform API health: {health.get('status')}")
# Validate security policy
policy = plugin_security_policy_manager.get_security_policy(self.plugin_id, None)
self.logger.info(f"Security policy loaded: {policy.get('max_api_calls_per_minute')} calls/min")
self.logger.info("Zammad plugin initialized successfully")
return True
except Exception as e:
self.logger.error(f"Failed to initialize Zammad plugin: {e}")
return False
async def _create_database_tables(self):
"""Create plugin database tables"""
try:
engine = await plugin_db_manager.get_plugin_engine(self.plugin_id)
if engine:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
self.logger.info("Database tables created successfully")
except Exception as e:
self.logger.error(f"Failed to create database tables: {e}")
raise
async def cleanup(self) -> bool:
"""Cleanup plugin resources"""
try:
self.logger.info("Cleaning up Zammad plugin")
# Close any open connections
return True
except Exception as e:
self.logger.error(f"Error during cleanup: {e}")
return False
def get_api_router(self) -> APIRouter:
"""Return FastAPI router for Zammad endpoints"""
router = APIRouter()
@router.get("/health")
async def health_check():
"""Plugin health check endpoint"""
return await self.health_check()
@router.get("/tickets")
async def get_tickets(context: PluginContext = Depends(self.get_auth_context)):
"""Get tickets from Zammad"""
try:
self._track_request()
config = await self.get_active_config(context.user_id)
if not config:
raise HTTPException(status_code=404, detail="No Zammad configuration found")
tickets = await self.fetch_tickets_from_zammad(config)
return {"tickets": tickets, "count": len(tickets)}
except Exception as e:
self._track_request(success=False)
self.logger.error(f"Error fetching tickets: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/tickets/{ticket_id}")
async def get_ticket(ticket_id: str, context: PluginContext = Depends(self.get_auth_context)):
"""Get specific ticket from Zammad"""
try:
self._track_request()
config = await self.get_active_config(context.user_id)
if not config:
raise HTTPException(status_code=404, detail="No Zammad configuration found")
ticket = await self.fetch_ticket_from_zammad(config, ticket_id)
return {"ticket": ticket}
except Exception as e:
self._track_request(success=False)
self.logger.error(f"Error fetching ticket {ticket_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/tickets/{ticket_id}/summarize")
async def summarize_ticket(
ticket_id: str,
background_tasks: BackgroundTasks,
context: PluginContext = Depends(self.get_auth_context)
):
"""Generate AI summary for ticket"""
try:
self._track_request()
config = await self.get_active_config(context.user_id)
if not config:
raise HTTPException(status_code=404, detail="No Zammad configuration found")
# Start summarization in background
background_tasks.add_task(
self.summarize_ticket_async,
config,
ticket_id,
context.user_id
)
return {
"status": "started",
"ticket_id": ticket_id,
"message": "AI summarization started in background"
}
except Exception as e:
self._track_request(success=False)
self.logger.error(f"Error starting summarization for ticket {ticket_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/webhooks/ticket-created")
async def handle_ticket_webhook(webhook_data: Dict[str, Any]):
"""Handle Zammad webhook for new tickets"""
try:
ticket_id = webhook_data.get("ticket", {}).get("id")
if not ticket_id:
raise HTTPException(status_code=400, detail="Invalid webhook data")
self.logger.info(f"Received webhook for ticket: {ticket_id}")
# Process webhook asynchronously
asyncio.create_task(self.process_ticket_webhook(webhook_data))
return {"status": "processed", "ticket_id": ticket_id}
except Exception as e:
self.logger.error(f"Error processing webhook: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/configurations")
async def get_configurations(context: PluginContext = Depends(self.get_auth_context)):
"""Get user's Zammad configurations"""
try:
configs = await self.get_user_configurations(context.user_id)
return {"configurations": configs}
except Exception as e:
self.logger.error(f"Error fetching configurations: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/configurations")
async def create_configuration(
config_data: Dict[str, Any],
context: PluginContext = Depends(self.get_auth_context)
):
"""Create new Zammad configuration"""
try:
# Validate configuration against schema
schema = await self.get_configuration_schema()
is_valid, errors = await self.config.validate_config(config_data, schema)
if not is_valid:
raise HTTPException(status_code=400, detail=f"Invalid configuration: {errors}")
# Test connection before saving
connection_test = await self.test_zammad_connection(config_data)
if not connection_test["success"]:
raise HTTPException(
status_code=400,
detail=f"Connection test failed: {connection_test['error']}"
)
# Save configuration to plugin database
success = await self._save_configuration_to_db(config_data, context.user_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to save configuration")
return {"status": "created", "config": {"name": config_data.get("name"), "zammad_url": config_data.get("zammad_url")}}
except HTTPException:
raise
except Exception as e:
self.logger.error(f"Error creating configuration: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/statistics")
async def get_statistics(context: PluginContext = Depends(self.get_auth_context)):
"""Get plugin usage statistics"""
try:
stats = await self._get_plugin_statistics(context.user_id)
return stats
except Exception as e:
self.logger.error(f"Error getting statistics: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/tickets/sync")
async def sync_tickets_manual(context: PluginContext = Depends(self.get_auth_context)):
"""Manually trigger ticket sync"""
try:
result = await self._sync_user_tickets(context.user_id)
return {"status": "completed", "synced_count": result}
except Exception as e:
self.logger.error(f"Error syncing tickets: {e}")
raise HTTPException(status_code=500, detail=str(e))
return router
# Plugin-specific methods
async def get_active_config(self, user_id: str) -> Optional[Dict[str, Any]]:
"""Get active Zammad configuration for user from database"""
try:
async with PluginDatabaseSession(self.plugin_id, plugin_db_manager) as db:
config = await db.query(ZammadConfiguration).filter(
ZammadConfiguration.user_id == user_id,
ZammadConfiguration.is_active == True
).first()
if config:
# Decrypt API token
from app.services.plugin_security import plugin_token_manager
api_token = plugin_token_manager.decrypt_plugin_secret(config.api_token_encrypted)
return {
"id": str(config.id),
"name": config.name,
"zammad_url": config.zammad_url,
"api_token": api_token,
"chatbot_id": config.chatbot_id,
"ai_summarization": {
"enabled": config.ai_summarization_enabled,
"auto_summarize": config.auto_summarize
},
"sync_settings": {
"enabled": config.sync_enabled,
"interval_hours": config.sync_interval_hours
}
}
return None
except Exception as e:
self.logger.error(f"Failed to get active config: {e}")
return None
async def get_user_configurations(self, user_id: str) -> List[Dict[str, Any]]:
"""Get all configurations for user from database"""
try:
async with PluginDatabaseSession(self.plugin_id, plugin_db_manager) as db:
configs = await db.query(ZammadConfiguration).filter(
ZammadConfiguration.user_id == user_id
).all()
result = []
for config in configs:
result.append({
"id": str(config.id),
"name": config.name,
"zammad_url": config.zammad_url,
"chatbot_id": config.chatbot_id,
"is_active": config.is_active,
"created_at": config.created_at.isoformat(),
"updated_at": config.updated_at.isoformat()
})
return result
except Exception as e:
self.logger.error(f"Failed to get user configurations: {e}")
return []
async def fetch_tickets_from_zammad(self, config: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Fetch tickets from Zammad API"""
async with aiohttp.ClientSession() as session:
headers = {
"Authorization": f"Token {config['api_token']}",
"Content-Type": "application/json"
}
async with session.get(
f"{config['zammad_url']}/api/v1/tickets",
headers=headers,
timeout=30
) as response:
if response.status != 200:
raise HTTPException(
status_code=response.status,
detail=f"Zammad API error: {await response.text()}"
)
return await response.json()
async def fetch_ticket_from_zammad(self, config: Dict[str, Any], ticket_id: str) -> Dict[str, Any]:
"""Fetch specific ticket from Zammad"""
async with aiohttp.ClientSession() as session:
headers = {
"Authorization": f"Token {config['api_token']}",
"Content-Type": "application/json"
}
async with session.get(
f"{config['zammad_url']}/api/v1/tickets/{ticket_id}",
headers=headers,
timeout=30
) as response:
if response.status != 200:
raise HTTPException(
status_code=response.status,
detail=f"Zammad API error: {await response.text()}"
)
return await response.json()
async def summarize_ticket_async(self, config: Dict[str, Any], ticket_id: str, user_id: str):
"""Asynchronously summarize a ticket using platform AI"""
try:
# Get ticket details
ticket = await self.fetch_ticket_from_zammad(config, ticket_id)
# Use platform chatbot API for summarization
chatbot_response = await self.api_client.call_chatbot_api(
chatbot_id=config["chatbot_id"],
message=f"Summarize this support ticket:\n\nTitle: {ticket.get('title', '')}\n\nContent: {ticket.get('body', '')}"
)
summary = chatbot_response.get("response", "")
# TODO: Store summary in database
self.logger.info(f"Generated summary for ticket {ticket_id}: {summary[:100]}...")
# Update ticket in Zammad with summary
await self.update_ticket_summary(config, ticket_id, summary)
except Exception as e:
self.logger.error(f"Error summarizing ticket {ticket_id}: {e}")
async def update_ticket_summary(self, config: Dict[str, Any], ticket_id: str, summary: str):
"""Update ticket with AI summary"""
async with aiohttp.ClientSession() as session:
headers = {
"Authorization": f"Token {config['api_token']}",
"Content-Type": "application/json"
}
update_data = {
"note": f"AI Summary: {summary}"
}
async with session.put(
f"{config['zammad_url']}/api/v1/tickets/{ticket_id}",
headers=headers,
json=update_data,
timeout=30
) as response:
if response.status not in [200, 201]:
self.logger.error(f"Failed to update ticket {ticket_id} with summary")
async def test_zammad_connection(self, config: Dict[str, Any]) -> Dict[str, Any]:
"""Test connection to Zammad instance"""
try:
async with aiohttp.ClientSession() as session:
headers = {
"Authorization": f"Token {config['api_token']}",
"Content-Type": "application/json"
}
async with session.get(
f"{config['zammad_url']}/api/v1/users/me",
headers=headers,
timeout=10
) as response:
if response.status == 200:
user_data = await response.json()
return {
"success": True,
"user": user_data.get("login", "unknown"),
"zammad_version": response.headers.get("X-Zammad-Version", "unknown")
}
else:
return {
"success": False,
"error": f"HTTP {response.status}: {await response.text()}"
}
except Exception as e:
return {
"success": False,
"error": str(e)
}
async def process_ticket_webhook(self, webhook_data: Dict[str, Any]):
"""Process ticket webhook asynchronously"""
try:
ticket_data = webhook_data.get("ticket", {})
ticket_id = ticket_data.get("id")
self.logger.info(f"Processing webhook for ticket {ticket_id}")
# TODO: Get configuration and auto-summarize if enabled
# This would require looking up the configuration associated with the webhook
except Exception as e:
self.logger.error(f"Error processing webhook: {e}")
# Cron job functions
async def sync_tickets_from_zammad(self) -> bool:
"""Sync tickets from Zammad (cron job)"""
try:
self.logger.info("Starting ticket sync from Zammad")
# TODO: Get all active configurations and sync tickets
# This would iterate through all user configurations
self.logger.info("Ticket sync completed successfully")
return True
except Exception as e:
self.logger.error(f"Ticket sync failed: {e}")
return False
async def cleanup_old_summaries(self) -> bool:
"""Clean up old AI summaries (cron job)"""
try:
self.logger.info("Starting cleanup of old summaries")
# TODO: Clean up summaries older than retention period
self.logger.info("Summary cleanup completed")
return True
except Exception as e:
self.logger.error(f"Summary cleanup failed: {e}")
return False
async def check_zammad_connection(self) -> bool:
"""Check Zammad connectivity (cron job)"""
try:
# TODO: Test all configured Zammad instances
self.logger.info("Zammad connectivity check completed")
return True
except Exception as e:
self.logger.error(f"Connectivity check failed: {e}")
return False
async def generate_weekly_reports(self) -> bool:
"""Generate weekly reports (cron job)"""
try:
self.logger.info("Generating weekly reports")
# TODO: Generate and send weekly ticket reports
self.logger.info("Weekly reports generated successfully")
return True
except Exception as e:
self.logger.error(f"Report generation failed: {e}")
return False
# Enhanced database integration methods
async def _save_configuration_to_db(self, config_data: Dict[str, Any], user_id: str) -> bool:
"""Save Zammad configuration to plugin database"""
try:
from app.services.plugin_security import plugin_token_manager
# Encrypt API token
encrypted_token = plugin_token_manager.encrypt_plugin_secret(config_data["api_token"])
async with PluginDatabaseSession(self.plugin_id, plugin_db_manager) as db:
# Deactivate existing configurations if this is set as active
if config_data.get("is_active", True):
await db.query(ZammadConfiguration).filter(
ZammadConfiguration.user_id == user_id,
ZammadConfiguration.is_active == True
).update({"is_active": False})
# Create new configuration
config = ZammadConfiguration(
user_id=user_id,
name=config_data["name"],
zammad_url=config_data["zammad_url"],
api_token_encrypted=encrypted_token,
chatbot_id=config_data["chatbot_id"],
is_active=config_data.get("is_active", True),
ai_summarization_enabled=config_data.get("ai_summarization", {}).get("enabled", True),
auto_summarize=config_data.get("ai_summarization", {}).get("auto_summarize", True),
sync_enabled=config_data.get("sync_settings", {}).get("enabled", True),
sync_interval_hours=config_data.get("sync_settings", {}).get("interval_hours", 2)
)
db.add(config)
await db.commit()
self.logger.info(f"Saved Zammad configuration for user {user_id}")
return True
except Exception as e:
self.logger.error(f"Failed to save configuration: {e}")
return False
async def _get_plugin_statistics(self, user_id: str) -> Dict[str, Any]:
"""Get plugin usage statistics"""
try:
async with PluginDatabaseSession(self.plugin_id, plugin_db_manager) as db:
# Get configuration count
config_count = await db.query(ZammadConfiguration).filter(
ZammadConfiguration.user_id == user_id
).count()
# Get ticket count
ticket_count = await db.query(ZammadTicket).join(ZammadConfiguration).filter(
ZammadConfiguration.user_id == user_id
).count()
# Get tickets with AI summaries
summarized_count = await db.query(ZammadTicket).join(ZammadConfiguration).filter(
ZammadConfiguration.user_id == user_id,
ZammadTicket.ai_summary.isnot(None)
).count()
# Get recent activity (last 7 days)
from datetime import timedelta
week_ago = datetime.now(timezone.utc) - timedelta(days=7)
recent_tickets = await db.query(ZammadTicket).join(ZammadConfiguration).filter(
ZammadConfiguration.user_id == user_id,
ZammadTicket.last_synced >= week_ago
).count()
return {
"configurations": config_count,
"total_tickets": ticket_count,
"tickets_with_summaries": summarized_count,
"recent_tickets": recent_tickets,
"summary_rate": round((summarized_count / max(ticket_count, 1)) * 100, 1),
"last_sync": datetime.now(timezone.utc).isoformat()
}
except Exception as e:
self.logger.error(f"Failed to get statistics: {e}")
return {
"error": str(e),
"configurations": 0,
"total_tickets": 0,
"tickets_with_summaries": 0,
"recent_tickets": 0,
"summary_rate": 0.0
}
async def _sync_user_tickets(self, user_id: str) -> int:
"""Sync tickets for a specific user"""
try:
config = await self.get_active_config(user_id)
if not config:
return 0
# Fetch tickets from Zammad
tickets = await self.fetch_tickets_from_zammad(config)
synced_count = 0
async with PluginDatabaseSession(self.plugin_id, plugin_db_manager) as db:
config_record = await db.query(ZammadConfiguration).filter(
ZammadConfiguration.id == config["id"]
).first()
if not config_record:
return 0
for ticket_data in tickets:
# Check if ticket already exists
existing_ticket = await db.query(ZammadTicket).filter(
ZammadTicket.zammad_ticket_id == str(ticket_data["id"]),
ZammadTicket.configuration_id == config_record.id
).first()
if existing_ticket:
# Update existing ticket
existing_ticket.title = ticket_data.get("title", "")
existing_ticket.body = ticket_data.get("body", "")
existing_ticket.status = ticket_data.get("state", "")
existing_ticket.priority = ticket_data.get("priority", "")
existing_ticket.last_synced = datetime.now(timezone.utc)
existing_ticket.updated_at = datetime.now(timezone.utc)
else:
# Create new ticket
new_ticket = ZammadTicket(
zammad_ticket_id=str(ticket_data["id"]),
configuration_id=config_record.id,
title=ticket_data.get("title", ""),
body=ticket_data.get("body", ""),
status=ticket_data.get("state", ""),
priority=ticket_data.get("priority", ""),
customer_id=str(ticket_data.get("customer_id", "")),
group_id=str(ticket_data.get("group_id", "")),
last_synced=datetime.now(timezone.utc)
)
db.add(new_ticket)
synced_count += 1
await db.commit()
self.logger.info(f"Synced {synced_count} new tickets for user {user_id}")
return synced_count
except Exception as e:
self.logger.error(f"Failed to sync tickets for user {user_id}: {e}")
return 0
async def _store_ticket_summary(self, ticket_id: str, summary: str, config_id: str):
"""Store AI-generated summary in database"""
try:
async with PluginDatabaseSession(self.plugin_id, plugin_db_manager) as db:
ticket = await db.query(ZammadTicket).filter(
ZammadTicket.zammad_ticket_id == ticket_id,
ZammadTicket.configuration_id == config_id
).first()
if ticket:
ticket.ai_summary = summary
ticket.updated_at = datetime.now(timezone.utc)
await db.commit()
self.logger.info(f"Stored AI summary for ticket {ticket_id}")
except Exception as e:
self.logger.error(f"Failed to store summary for ticket {ticket_id}: {e}")

View File

@@ -1,253 +0,0 @@
apiVersion: "v1"
kind: "Plugin"
metadata:
name: "zammad"
version: "1.0.0"
description: "Zammad helpdesk integration with AI summarization and ticket management"
author: "Enclava Team"
license: "MIT"
homepage: "https://github.com/enclava/plugins/zammad"
repository: "https://github.com/enclava/plugins/zammad"
tags:
- "helpdesk"
- "ticket-management"
- "ai-summarization"
- "integration"
spec:
runtime:
python_version: "3.11"
dependencies:
- "aiohttp>=3.8.0"
- "pydantic>=2.0.0"
- "httpx>=0.24.0"
- "python-dateutil>=2.8.0"
environment_variables:
ZAMMAD_TIMEOUT: "30"
ZAMMAD_MAX_RETRIES: "3"
permissions:
platform_apis:
- "chatbot:invoke"
- "rag:query"
- "llm:completion"
- "llm:embeddings"
plugin_scopes:
- "tickets:read"
- "tickets:write"
- "tickets:summarize"
- "webhooks:receive"
- "config:manage"
- "sync:execute"
external_domains:
- "*.zammad.com"
- "*.zammad.org"
- "api.zammad.org"
database:
schema: "plugin_zammad"
migrations_path: "./migrations"
auto_migrate: true
api_endpoints:
- path: "/tickets"
methods: ["GET", "POST"]
description: "List and create Zammad tickets"
auth_required: true
- path: "/tickets/{ticket_id}"
methods: ["GET", "PUT", "DELETE"]
description: "Get, update, or delete specific ticket"
auth_required: true
- path: "/tickets/{ticket_id}/summarize"
methods: ["POST"]
description: "Generate AI summary for ticket"
auth_required: true
- path: "/tickets/{ticket_id}/articles"
methods: ["GET", "POST"]
description: "Get ticket articles or add new article"
auth_required: true
- path: "/webhooks/ticket-created"
methods: ["POST"]
description: "Handle Zammad webhook for new tickets"
auth_required: false
- path: "/webhooks/ticket-updated"
methods: ["POST"]
description: "Handle Zammad webhook for updated tickets"
auth_required: false
- path: "/configurations"
methods: ["GET", "POST", "PUT", "DELETE"]
description: "Manage Zammad configurations"
auth_required: true
- path: "/configurations/{config_id}/test"
methods: ["POST"]
description: "Test Zammad configuration connection"
auth_required: true
- path: "/statistics"
methods: ["GET"]
description: "Get plugin usage statistics"
auth_required: true
- path: "/health"
methods: ["GET"]
description: "Plugin health check"
auth_required: false
cron_jobs:
- name: "sync_tickets"
schedule: "0 */2 * * *"
function: "sync_tickets_from_zammad"
description: "Sync tickets from Zammad every 2 hours"
enabled: true
timeout_seconds: 600
max_retries: 3
- name: "cleanup_summaries"
schedule: "0 3 * * 0"
function: "cleanup_old_summaries"
description: "Clean up old AI summaries weekly"
enabled: true
timeout_seconds: 300
max_retries: 1
- name: "health_check"
schedule: "*/15 * * * *"
function: "check_zammad_connection"
description: "Check Zammad API connectivity every 15 minutes"
enabled: true
timeout_seconds: 60
max_retries: 2
- name: "generate_reports"
schedule: "0 9 * * 1"
function: "generate_weekly_reports"
description: "Generate weekly ticket reports"
enabled: false
timeout_seconds: 900
max_retries: 2
ui_config:
configuration_schema: "./config_schema.json"
ui_components: "./ui/components"
pages:
- name: "dashboard"
path: "/plugins/zammad"
component: "ZammadDashboard"
- name: "settings"
path: "/plugins/zammad/settings"
component: "ZammadSettings"
- name: "tickets"
path: "/plugins/zammad/tickets"
component: "ZammadTicketList"
- name: "analytics"
path: "/plugins/zammad/analytics"
component: "ZammadAnalytics"
external_services:
allowed_domains:
- "*.zammad.com"
- "*.zammad.org"
- "api.zammad.org"
- "help.zammad.com"
webhooks:
- endpoint: "/webhooks/ticket-created"
security: "signature_validation"
- endpoint: "/webhooks/ticket-updated"
security: "signature_validation"
rate_limits:
"*.zammad.com": 100
"*.zammad.org": 100
"api.zammad.org": 200
config_schema:
type: "object"
required:
- "zammad_url"
- "api_token"
- "chatbot_id"
properties:
zammad_url:
type: "string"
format: "uri"
title: "Zammad URL"
description: "The base URL of your Zammad instance"
examples:
- "https://company.zammad.com"
- "https://support.example.com"
api_token:
type: "string"
title: "API Token"
description: "Zammad API token with ticket read/write permissions"
minLength: 20
format: "password"
chatbot_id:
type: "string"
title: "Chatbot ID"
description: "Platform chatbot ID for AI summarization"
examples:
- "zammad-summarizer"
- "ticket-assistant"
ai_summarization:
type: "object"
title: "AI Summarization Settings"
properties:
enabled:
type: "boolean"
title: "Enable AI Summarization"
description: "Automatically summarize tickets using AI"
default: true
model:
type: "string"
title: "AI Model"
description: "LLM model to use for summarization"
default: "gpt-3.5-turbo"
max_tokens:
type: "integer"
title: "Max Summary Tokens"
description: "Maximum tokens for AI summary"
minimum: 50
maximum: 500
default: 150
draft_settings:
type: "object"
title: "AI Draft Settings"
properties:
enabled:
type: "boolean"
title: "Enable AI Drafts"
description: "Generate AI draft responses for tickets"
default: false
model:
type: "string"
title: "Draft Model"
description: "LLM model to use for draft generation"
default: "gpt-3.5-turbo"
max_tokens:
type: "integer"
title: "Max Draft Tokens"
description: "Maximum tokens for AI draft responses"
minimum: 100
maximum: 1000
default: 300

View File

@@ -1,85 +0,0 @@
"""Alembic environment for Zammad plugin"""
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import os
import sys
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
from main import Base
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
# Get database URL from environment variable
url = os.getenv("DATABASE_URL")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# Get database URL from environment variable
database_url = os.getenv("DATABASE_URL")
configuration = config.get_section(config.config_ini_section)
configuration["sqlalchemy.url"] = database_url
connectable = engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -1,24 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -1,112 +0,0 @@
"""Initial Zammad plugin schema
Revision ID: 001
Revises:
Create Date: 2024-12-22 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision = '001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create initial Zammad plugin schema"""
# Create zammad_configurations table
op.create_table(
'zammad_configurations',
sa.Column('id', UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
sa.Column('user_id', sa.String(255), nullable=False),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('zammad_url', sa.String(500), nullable=False),
sa.Column('api_token_encrypted', sa.Text(), nullable=False),
sa.Column('chatbot_id', sa.String(100), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('TRUE')),
sa.Column('ai_summarization_enabled', sa.Boolean(), nullable=False, server_default=sa.text('TRUE')),
sa.Column('auto_summarize', sa.Boolean(), nullable=False, server_default=sa.text('TRUE')),
sa.Column('sync_enabled', sa.Boolean(), nullable=False, server_default=sa.text('TRUE')),
sa.Column('sync_interval_hours', sa.Integer(), nullable=False, server_default=sa.text('2')),
sa.Column('created_at', sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
)
# Create zammad_tickets table
op.create_table(
'zammad_tickets',
sa.Column('id', UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
sa.Column('zammad_ticket_id', sa.String(50), nullable=False),
sa.Column('configuration_id', UUID(as_uuid=True), nullable=True),
sa.Column('title', sa.String(500), nullable=False),
sa.Column('body', sa.Text(), nullable=True),
sa.Column('status', sa.String(50), nullable=True),
sa.Column('priority', sa.String(50), nullable=True),
sa.Column('customer_id', sa.String(50), nullable=True),
sa.Column('group_id', sa.String(50), nullable=True),
sa.Column('ai_summary', sa.Text(), nullable=True),
sa.Column('last_synced', sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('created_at', sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.ForeignKeyConstraint(['configuration_id'], ['zammad_configurations.id'], ondelete='CASCADE'),
)
# Create indexes for performance
op.create_index('idx_zammad_configurations_user_id', 'zammad_configurations', ['user_id'])
op.create_index('idx_zammad_configurations_user_active', 'zammad_configurations', ['user_id', 'is_active'])
op.create_index('idx_zammad_tickets_zammad_id', 'zammad_tickets', ['zammad_ticket_id'])
op.create_index('idx_zammad_tickets_config_id', 'zammad_tickets', ['configuration_id'])
op.create_index('idx_zammad_tickets_status', 'zammad_tickets', ['status'])
op.create_index('idx_zammad_tickets_last_synced', 'zammad_tickets', ['last_synced'])
# Create updated_at trigger function if it doesn't exist
op.execute("""
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE 'plpgsql';
""")
# Create triggers to automatically update updated_at columns
op.execute("""
CREATE TRIGGER update_zammad_configurations_updated_at
BEFORE UPDATE ON zammad_configurations
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
""")
op.execute("""
CREATE TRIGGER update_zammad_tickets_updated_at
BEFORE UPDATE ON zammad_tickets
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
""")
def downgrade() -> None:
"""Drop Zammad plugin schema"""
# Drop triggers first
op.execute("DROP TRIGGER IF EXISTS update_zammad_tickets_updated_at ON zammad_tickets;")
op.execute("DROP TRIGGER IF EXISTS update_zammad_configurations_updated_at ON zammad_configurations;")
# Drop indexes
op.drop_index('idx_zammad_tickets_last_synced')
op.drop_index('idx_zammad_tickets_status')
op.drop_index('idx_zammad_tickets_config_id')
op.drop_index('idx_zammad_tickets_zammad_id')
op.drop_index('idx_zammad_configurations_user_active')
op.drop_index('idx_zammad_configurations_user_id')
# Drop tables (tickets first due to foreign key)
op.drop_table('zammad_tickets')
op.drop_table('zammad_configurations')
# Note: We don't drop the update_updated_at_column function as it might be used by other tables

View File

@@ -1,4 +0,0 @@
aiohttp>=3.8.0
pydantic>=2.0.0
httpx>=0.24.0
python-dateutil>=2.8.0

View File

@@ -1,414 +0,0 @@
/**
* Zammad Plugin Dashboard Component
* Main dashboard for Zammad plugin showing tickets, statistics, and quick actions
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Grid,
Card,
CardContent,
Typography,
Button,
Chip,
Alert,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
LinearProgress,
Tooltip
} from '@mui/material';
import {
Refresh as RefreshIcon,
Sync as SyncIcon,
Analytics as AnalyticsIcon,
Assignment as TicketIcon,
AutoAwesome as AIIcon,
Settings as SettingsIcon,
OpenInNew as OpenIcon
} from '@mui/icons-material';
interface ZammadTicket {
id: string;
title: string;
status: string;
priority: string;
customer_id: string;
created_at: string;
ai_summary?: string;
}
interface ZammadStats {
configurations: number;
total_tickets: number;
tickets_with_summaries: number;
recent_tickets: number;
summary_rate: number;
last_sync: string;
}
export const ZammadDashboard: React.FC = () => {
const [tickets, setTickets] = useState<ZammadTicket[]>([]);
const [stats, setStats] = useState<ZammadStats | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedTicket, setSelectedTicket] = useState<ZammadTicket | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [syncing, setSyncing] = useState(false);
useEffect(() => {
loadDashboardData();
}, []);
const loadDashboardData = async () => {
setLoading(true);
setError(null);
try {
// Load statistics
const statsResponse = await fetch('/api/v1/plugins/zammad/statistics');
if (statsResponse.ok) {
const statsData = await statsResponse.json();
setStats(statsData);
}
// Load recent tickets
const ticketsResponse = await fetch('/api/v1/plugins/zammad/tickets?limit=10');
if (ticketsResponse.ok) {
const ticketsData = await ticketsResponse.json();
setTickets(ticketsData.tickets || []);
}
} catch (err) {
setError('Failed to load dashboard data');
console.error('Dashboard load error:', err);
} finally {
setLoading(false);
}
};
const handleSyncTickets = async () => {
setSyncing(true);
try {
const response = await fetch('/api/v1/plugins/zammad/tickets/sync', {
method: 'GET'
});
if (response.ok) {
const result = await response.json();
// Reload dashboard data after sync
await loadDashboardData();
// Show success message with sync count
console.log(`Synced ${result.synced_count} tickets`);
} else {
throw new Error('Sync failed');
}
} catch (err) {
setError('Failed to sync tickets');
} finally {
setSyncing(false);
}
};
const handleTicketClick = (ticket: ZammadTicket) => {
setSelectedTicket(ticket);
setDialogOpen(true);
};
const handleSummarizeTicket = async (ticketId: string) => {
try {
const response = await fetch(`/api/v1/plugins/zammad/tickets/${ticketId}/summarize`, {
method: 'POST'
});
if (response.ok) {
// Show success message
console.log('Summarization started');
}
} catch (err) {
console.error('Summarization failed:', err);
}
};
const getStatusColor = (status: string) => {
switch (status.toLowerCase()) {
case 'open': return 'error';
case 'pending': return 'warning';
case 'closed': return 'success';
default: return 'default';
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case '3 high': return 'error';
case '2 normal': return 'warning';
case '1 low': return 'success';
default: return 'default';
}
};
return (
<Box>
{/* Header */}
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
<Typography variant="h4" component="h1">
Zammad Dashboard
</Typography>
<Box display="flex" gap={2}>
<Button
variant="outlined"
startIcon={<SyncIcon />}
onClick={handleSyncTickets}
disabled={syncing}
>
{syncing ? 'Syncing...' : 'Sync Tickets'}
</Button>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={loadDashboardData}
disabled={loading}
>
Refresh
</Button>
</Box>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{loading && <LinearProgress sx={{ mb: 3 }} />}
{/* Statistics Cards */}
{stats && (
<Grid container spacing={3} sx={{ mb: 4 }}>
<Grid item xs={12} sm={6} md={3}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" gap={2}>
<TicketIcon color="primary" />
<Box>
<Typography variant="h6">{stats.total_tickets}</Typography>
<Typography variant="body2" color="text.secondary">
Total Tickets
</Typography>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" gap={2}>
<AIIcon color="secondary" />
<Box>
<Typography variant="h6">{stats.tickets_with_summaries}</Typography>
<Typography variant="body2" color="text.secondary">
AI Summaries
</Typography>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" gap={2}>
<AnalyticsIcon color="success" />
<Box>
<Typography variant="h6">{stats.summary_rate}%</Typography>
<Typography variant="body2" color="text.secondary">
Summary Rate
</Typography>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" gap={2}>
<RefreshIcon color="info" />
<Box>
<Typography variant="h6">{stats.recent_tickets}</Typography>
<Typography variant="body2" color="text.secondary">
Recent (7 days)
</Typography>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
</Grid>
)}
{/* Recent Tickets Table */}
<Card>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h6">Recent Tickets</Typography>
<Button
size="small"
endIcon={<OpenIcon />}
onClick={() => window.location.hash = '#/plugins/zammad/tickets'}
>
View All
</Button>
</Box>
{tickets.length === 0 ? (
<Typography variant="body2" color="text.secondary" textAlign="center" py={4}>
No tickets found. Try syncing with Zammad.
</Typography>
) : (
<Table>
<TableHead>
<TableRow>
<TableCell>Title</TableCell>
<TableCell>Status</TableCell>
<TableCell>Priority</TableCell>
<TableCell>AI Summary</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{tickets.map((ticket) => (
<TableRow key={ticket.id} hover onClick={() => handleTicketClick(ticket)}>
<TableCell>
<Typography variant="body2" noWrap sx={{ maxWidth: 200 }}>
{ticket.title}
</Typography>
</TableCell>
<TableCell>
<Chip
label={ticket.status}
color={getStatusColor(ticket.status) as any}
size="small"
/>
</TableCell>
<TableCell>
<Chip
label={ticket.priority}
color={getPriorityColor(ticket.priority) as any}
size="small"
variant="outlined"
/>
</TableCell>
<TableCell>
{ticket.ai_summary ? (
<Chip label="Available" color="success" size="small" />
) : (
<Chip label="None" color="default" size="small" />
)}
</TableCell>
<TableCell>
<Tooltip title="Generate AI Summary">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleSummarizeTicket(ticket.id);
}}
disabled={!!ticket.ai_summary}
>
<AIIcon />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Ticket Detail Dialog */}
<Dialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>
Ticket Details
</DialogTitle>
<DialogContent>
{selectedTicket && (
<Box>
<Typography variant="h6" gutterBottom>
{selectedTicket.title}
</Typography>
<Box display="flex" gap={2} mb={2}>
<Chip label={selectedTicket.status} color={getStatusColor(selectedTicket.status) as any} />
<Chip label={selectedTicket.priority} color={getPriorityColor(selectedTicket.priority) as any} />
</Box>
<Typography variant="body2" color="text.secondary" paragraph>
Customer: {selectedTicket.customer_id}
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
Created: {new Date(selectedTicket.created_at).toLocaleString()}
</Typography>
{selectedTicket.ai_summary && (
<Box mt={2}>
<Typography variant="subtitle2" gutterBottom>
AI Summary
</Typography>
<Typography variant="body2" sx={{
backgroundColor: 'grey.100',
p: 2,
borderRadius: 1
}}>
{selectedTicket.ai_summary}
</Typography>
</Box>
)}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>
Close
</Button>
{selectedTicket && !selectedTicket.ai_summary && (
<Button
variant="contained"
startIcon={<AIIcon />}
onClick={() => {
handleSummarizeTicket(selectedTicket.id);
setDialogOpen(false);
}}
>
Generate Summary
</Button>
)}
</DialogActions>
</Dialog>
</Box>
);
};

View File

@@ -1,512 +0,0 @@
/**
* Zammad Plugin Settings Component
* Configuration interface for Zammad plugin
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Card,
CardContent,
Typography,
TextField,
Button,
Switch,
FormControlLabel,
FormGroup,
Select,
MenuItem,
FormControl,
InputLabel,
Alert,
Divider,
Accordion,
AccordionSummary,
AccordionDetails,
Chip,
LinearProgress
} from '@mui/material';
import {
ExpandMore as ExpandMoreIcon,
Save as SaveIcon,
TestTube as TestIcon,
Security as SecurityIcon,
Sync as SyncIcon,
Smart as AIIcon
} from '@mui/icons-material';
interface ZammadConfig {
name: string;
zammad_url: string;
api_token: string;
chatbot_id: string;
ai_summarization: {
enabled: boolean;
model: string;
max_tokens: number;
auto_summarize: boolean;
};
sync_settings: {
enabled: boolean;
interval_hours: number;
sync_articles: boolean;
max_tickets_per_sync: number;
};
webhook_settings: {
secret: string;
enabled_events: string[];
};
notification_settings: {
email_notifications: boolean;
slack_webhook_url: string;
notification_events: string[];
};
}
const defaultConfig: ZammadConfig = {
name: '',
zammad_url: '',
api_token: '',
chatbot_id: '',
ai_summarization: {
enabled: true,
model: 'gpt-3.5-turbo',
max_tokens: 150,
auto_summarize: true
},
sync_settings: {
enabled: true,
interval_hours: 2,
sync_articles: true,
max_tickets_per_sync: 100
},
webhook_settings: {
secret: '',
enabled_events: ['ticket.create', 'ticket.update']
},
notification_settings: {
email_notifications: false,
slack_webhook_url: '',
notification_events: ['sync_error', 'api_error']
}
};
export const ZammadSettings: React.FC = () => {
const [config, setConfig] = useState<ZammadConfig>(defaultConfig);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [testResult, setTestResult] = useState<any>(null);
useEffect(() => {
loadConfiguration();
}, []);
const loadConfiguration = async () => {
setLoading(true);
try {
const response = await fetch('/api/v1/plugins/zammad/configurations');
if (response.ok) {
const data = await response.json();
if (data.configurations.length > 0) {
// Load the first (active) configuration
const loadedConfig = data.configurations[0];
setConfig({
...defaultConfig,
...loadedConfig
});
}
}
} catch (err) {
setError('Failed to load configuration');
} finally {
setLoading(false);
}
};
const handleConfigChange = (path: string, value: any) => {
setConfig(prev => {
const newConfig = { ...prev };
const keys = path.split('.');
let current: any = newConfig;
for (let i = 0; i < keys.length - 1; i++) {
current = current[keys[i]];
}
current[keys[keys.length - 1]] = value;
return newConfig;
});
};
const handleTestConnection = async () => {
setTesting(true);
setTestResult(null);
setError(null);
try {
const response = await fetch('/api/v1/plugins/zammad/configurations/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
zammad_url: config.zammad_url,
api_token: config.api_token
})
});
const result = await response.json();
setTestResult(result);
if (!result.success) {
setError(`Connection test failed: ${result.error}`);
}
} catch (err) {
setError('Connection test failed');
} finally {
setTesting(false);
}
};
const handleSaveConfiguration = async () => {
setSaving(true);
setError(null);
setSuccess(null);
try {
const response = await fetch('/api/v1/plugins/zammad/configurations', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(config)
});
if (response.ok) {
setSuccess('Configuration saved successfully');
} else {
const errorData = await response.json();
setError(errorData.detail || 'Failed to save configuration');
}
} catch (err) {
setError('Failed to save configuration');
} finally {
setSaving(false);
}
};
const handleArrayToggle = (path: string, value: string) => {
const currentArray = path.split('.').reduce((obj, key) => obj[key], config) as string[];
const newArray = currentArray.includes(value)
? currentArray.filter(item => item !== value)
: [...currentArray, value];
handleConfigChange(path, newArray);
};
if (loading) {
return (
<Box>
<Typography variant="h4" gutterBottom>Zammad Settings</Typography>
<LinearProgress />
</Box>
);
}
return (
<Box>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
<Typography variant="h4" component="h1">
Zammad Settings
</Typography>
<Box display="flex" gap={2}>
<Button
variant="outlined"
startIcon={<TestIcon />}
onClick={handleTestConnection}
disabled={testing || !config.zammad_url || !config.api_token}
>
{testing ? 'Testing...' : 'Test Connection'}
</Button>
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={handleSaveConfiguration}
disabled={saving}
>
{saving ? 'Saving...' : 'Save Configuration'}
</Button>
</Box>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 3 }}>
{success}
</Alert>
)}
{testResult && (
<Alert
severity={testResult.success ? 'success' : 'error'}
sx={{ mb: 3 }}
>
{testResult.success
? `Connection successful! User: ${testResult.user}, Version: ${testResult.zammad_version}`
: `Connection failed: ${testResult.error}`
}
</Alert>
)}
{/* Basic Configuration */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Basic Configuration
</Typography>
<Box display="flex" flexDirection="column" gap={3}>
<TextField
label="Configuration Name"
value={config.name}
onChange={(e) => handleConfigChange('name', e.target.value)}
fullWidth
required
/>
<TextField
label="Zammad URL"
value={config.zammad_url}
onChange={(e) => handleConfigChange('zammad_url', e.target.value)}
fullWidth
required
placeholder="https://company.zammad.com"
/>
<TextField
label="API Token"
type="password"
value={config.api_token}
onChange={(e) => handleConfigChange('api_token', e.target.value)}
fullWidth
required
helperText="Zammad API token with ticket read/write permissions"
/>
<TextField
label="Chatbot ID"
value={config.chatbot_id}
onChange={(e) => handleConfigChange('chatbot_id', e.target.value)}
fullWidth
required
helperText="Platform chatbot ID for AI summarization"
/>
</Box>
</CardContent>
</Card>
{/* AI Summarization Settings */}
<Accordion sx={{ mb: 2 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box display="flex" alignItems="center" gap={2}>
<AIIcon />
<Typography variant="h6">AI Summarization</Typography>
<Chip
label={config.ai_summarization.enabled ? 'Enabled' : 'Disabled'}
color={config.ai_summarization.enabled ? 'success' : 'default'}
size="small"
/>
</Box>
</AccordionSummary>
<AccordionDetails>
<Box display="flex" flexDirection="column" gap={3}>
<FormControlLabel
control={
<Switch
checked={config.ai_summarization.enabled}
onChange={(e) => handleConfigChange('ai_summarization.enabled', e.target.checked)}
/>
}
label="Enable AI Summarization"
/>
<FormControl fullWidth>
<InputLabel>AI Model</InputLabel>
<Select
value={config.ai_summarization.model}
onChange={(e) => handleConfigChange('ai_summarization.model', e.target.value)}
label="AI Model"
>
<MenuItem value="gpt-3.5-turbo">GPT-3.5 Turbo</MenuItem>
<MenuItem value="gpt-4">GPT-4</MenuItem>
<MenuItem value="claude-3-sonnet">Claude 3 Sonnet</MenuItem>
</Select>
</FormControl>
<TextField
label="Max Summary Tokens"
type="number"
value={config.ai_summarization.max_tokens}
onChange={(e) => handleConfigChange('ai_summarization.max_tokens', parseInt(e.target.value))}
inputProps={{ min: 50, max: 500 }}
/>
<FormControlLabel
control={
<Switch
checked={config.ai_summarization.auto_summarize}
onChange={(e) => handleConfigChange('ai_summarization.auto_summarize', e.target.checked)}
/>
}
label="Auto-summarize New Tickets"
/>
</Box>
</AccordionDetails>
</Accordion>
{/* Sync Settings */}
<Accordion sx={{ mb: 2 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box display="flex" alignItems="center" gap={2}>
<SyncIcon />
<Typography variant="h6">Sync Settings</Typography>
<Chip
label={config.sync_settings.enabled ? 'Enabled' : 'Disabled'}
color={config.sync_settings.enabled ? 'success' : 'default'}
size="small"
/>
</Box>
</AccordionSummary>
<AccordionDetails>
<Box display="flex" flexDirection="column" gap={3}>
<FormControlLabel
control={
<Switch
checked={config.sync_settings.enabled}
onChange={(e) => handleConfigChange('sync_settings.enabled', e.target.checked)}
/>
}
label="Enable Automatic Sync"
/>
<TextField
label="Sync Interval (Hours)"
type="number"
value={config.sync_settings.interval_hours}
onChange={(e) => handleConfigChange('sync_settings.interval_hours', parseInt(e.target.value))}
inputProps={{ min: 1, max: 24 }}
/>
<FormControlLabel
control={
<Switch
checked={config.sync_settings.sync_articles}
onChange={(e) => handleConfigChange('sync_settings.sync_articles', e.target.checked)}
/>
}
label="Sync Ticket Articles"
/>
<TextField
label="Max Tickets Per Sync"
type="number"
value={config.sync_settings.max_tickets_per_sync}
onChange={(e) => handleConfigChange('sync_settings.max_tickets_per_sync', parseInt(e.target.value))}
inputProps={{ min: 10, max: 1000 }}
/>
</Box>
</AccordionDetails>
</Accordion>
{/* Webhook Settings */}
<Accordion sx={{ mb: 2 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box display="flex" alignItems="center" gap={2}>
<SecurityIcon />
<Typography variant="h6">Webhook Settings</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Box display="flex" flexDirection="column" gap={3}>
<TextField
label="Webhook Secret"
type="password"
value={config.webhook_settings.secret}
onChange={(e) => handleConfigChange('webhook_settings.secret', e.target.value)}
fullWidth
helperText="Secret for webhook signature validation"
/>
<Typography variant="subtitle2">Enabled Webhook Events</Typography>
<FormGroup>
{['ticket.create', 'ticket.update', 'ticket.close', 'article.create'].map((event) => (
<FormControlLabel
key={event}
control={
<Switch
checked={config.webhook_settings.enabled_events.includes(event)}
onChange={() => handleArrayToggle('webhook_settings.enabled_events', event)}
/>
}
label={event}
/>
))}
</FormGroup>
</Box>
</AccordionDetails>
</Accordion>
{/* Notification Settings */}
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Notification Settings</Typography>
</AccordionSummary>
<AccordionDetails>
<Box display="flex" flexDirection="column" gap={3}>
<FormControlLabel
control={
<Switch
checked={config.notification_settings.email_notifications}
onChange={(e) => handleConfigChange('notification_settings.email_notifications', e.target.checked)}
/>
}
label="Email Notifications"
/>
<TextField
label="Slack Webhook URL"
value={config.notification_settings.slack_webhook_url}
onChange={(e) => handleConfigChange('notification_settings.slack_webhook_url', e.target.value)}
fullWidth
placeholder="https://hooks.slack.com/services/..."
/>
<Typography variant="subtitle2">Notification Events</Typography>
<FormGroup>
{['sync_error', 'api_error', 'new_tickets', 'summarization_complete'].map((event) => (
<FormControlLabel
key={event}
control={
<Switch
checked={config.notification_settings.notification_events.includes(event)}
onChange={() => handleArrayToggle('notification_settings.notification_events', event)}
/>
}
label={event.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
/>
))}
</FormGroup>
</Box>
</AccordionDetails>
</Accordion>
</Box>
);
};