diff --git a/README.md b/README.md index 580d292..d934dda 100644 --- a/README.md +++ b/README.md @@ -319,11 +319,55 @@ curl -s -X POST http://localhost:26000/v1/chat/completions \ }' ``` +## AI 서버 관리 페이지 (Admin Dashboard) + +AI 서버의 효율적인 관리를 위한 웹 기반 관리 페이지를 제공합니다. + +### 관리 페이지 접근 +- **URL**: `http://localhost:26000/admin` +- **인증**: API 키 기반 (환경변수 `API_KEY` 설정 필요) + +### 주요 기능 + +#### Phase 1: 기본 관리 기능 ✅ +- **시스템 상태 대시보드**: 서버/Ollama/모델 상태 실시간 모니터링 +- **모델 관리**: 설치된 모델 목록, 활성 모델 현황, 모델별 사용 통계 +- **API 키 관리**: 키 생성/조회/삭제, 사용량 모니터링 + +#### Phase 2: 고급 기능 (계획) +- **모델 다운로드/삭제**: Ollama 모델 원격 관리 +- **실시간 모니터링**: CPU/메모리/GPU 사용률, API 호출 통계 +- **설정 관리**: 환경변수 편집, Paperless 연동 설정 + +#### Phase 3: 보안 강화 (계획) +- **인증 시스템**: JWT 기반 로그인, 2FA 지원 +- **접근 제어**: IP 화이트리스트, 권한 관리 +- **감사 로그**: 모든 관리 작업 기록 및 추적 + +### 보안 고려사항 +- **API 키 암호화**: AES-256 암호화 저장 +- **HTTPS 강제**: SSL/TLS 인증서 필수 +- **접근 로그**: 모든 관리 페이지 접근 기록 +- **민감 정보 보호**: 로그에서 API 키 자동 마스킹 + +### 사용 예시 +```bash +# API 키 설정 +export API_KEY=your-secure-api-key + +# 서버 실행 +python -m server.main + +# 관리 페이지 접근 +curl -H "X-API-Key: your-secure-api-key" http://localhost:26000/admin +``` + ## 이 저장소 사용 계획 -1) Ollama API를 감싸는 경량 서버(Express 또는 FastAPI) 추가 -2) 표준화된 엔드포인트(`/v1/chat/completions`, `/v1/completions`) 제공 -3) 헬스체크/모델 선택/리밋/로깅 옵션 제공 +1) ✅ Ollama API를 감싸는 경량 서버(FastAPI) 구현 완료 +2) ✅ 표준화된 엔드포인트(`/v1/chat/completions`, `/v1/completions`) 제공 +3) ✅ 헬스체크/모델 선택/리밋/로깅 옵션 제공 +4) 🚧 웹 기반 관리 페이지 구현 중 (Phase 1) -우선 본 문서로 설치/선택 가이드를 정리했으며, 다음 단계에서 서버 스켈레톤과 샘플 클라이언트를 추가할 예정입니다. +우선 본 문서로 설치/선택 가이드를 정리했으며, 현재 관리 페이지와 고급 기능들을 단계적으로 추가하고 있습니다. diff --git a/server/main.py b/server/main.py index 82db7f6..046150f 100644 --- a/server/main.py +++ b/server/main.py @@ -471,3 +471,127 @@ async def chat_page(request: Request): "api_key": os.getenv("API_KEY", "") }) + +# Admin Dashboard Routes +@app.get("/admin", response_class=HTMLResponse) +async def admin_dashboard(request: Request, api_key: str = Depends(require_api_key)): + """관리자 대시보드 페이지""" + return templates.TemplateResponse("admin.html", { + "request": request, + "server_port": settings.ai_server_port, + "ollama_host": settings.ollama_host, + }) + + +@app.get("/admin/ollama/status") +async def admin_ollama_status(api_key: str = Depends(require_api_key)): + """Ollama 서버 상태 확인""" + try: + # Ollama 서버에 ping 요청 + response = await ollama.client.get(f"{settings.ollama_host}/api/tags") + if response.status_code == 200: + return {"status": "online", "models_count": len(response.json().get("models", []))} + else: + return {"status": "offline", "error": f"HTTP {response.status_code}"} + except Exception as e: + return {"status": "offline", "error": str(e)} + + +@app.get("/admin/models") +async def admin_get_models(api_key: str = Depends(require_api_key)): + """설치된 모델 목록 조회""" + try: + models_data = await ollama.list_models() + models = [] + + for model in models_data.get("models", []): + models.append({ + "name": model.get("name", "Unknown"), + "size": model.get("size", 0), + "status": "ready", + "is_active": model.get("name") == settings.base_model, + "last_used": model.get("modified_at"), + }) + + return {"models": models} + except Exception as e: + return {"models": [], "error": str(e)} + + +@app.get("/admin/models/active") +async def admin_get_active_model(api_key: str = Depends(require_api_key)): + """현재 활성 모델 조회""" + return {"model": settings.base_model} + + +@app.post("/admin/models/test") +async def admin_test_model(request: dict, api_key: str = Depends(require_api_key)): + """모델 테스트""" + model_name = request.get("model") + if not model_name: + raise HTTPException(status_code=400, detail="Model name is required") + + try: + # 간단한 테스트 메시지 전송 + test_response = await ollama.generate( + model=model_name, + prompt="Hello, this is a test. Please respond with 'Test successful'.", + stream=False + ) + + return { + "result": f"Test successful. Model responded: {test_response.get('response', 'No response')[:100]}..." + } + except Exception as e: + return {"result": f"Test failed: {str(e)}"} + + +# API Key Management (Placeholder - 실제 구현은 데이터베이스 필요) +api_keys_store = {} # 임시 저장소 + + +@app.get("/admin/api-keys") +async def admin_get_api_keys(api_key: str = Depends(require_api_key)): + """API 키 목록 조회""" + keys = [] + for key_id, key_data in api_keys_store.items(): + keys.append({ + "id": key_id, + "name": key_data.get("name", "Unnamed"), + "key": key_data.get("key", ""), + "created_at": key_data.get("created_at", datetime.now().isoformat()), + "usage_count": key_data.get("usage_count", 0), + }) + + return {"api_keys": keys} + + +@app.post("/admin/api-keys") +async def admin_create_api_key(request: dict, api_key: str = Depends(require_api_key)): + """새 API 키 생성""" + import secrets + import uuid + + name = request.get("name", "Unnamed Key") + new_key = secrets.token_urlsafe(32) + key_id = str(uuid.uuid4()) + + api_keys_store[key_id] = { + "name": name, + "key": new_key, + "created_at": datetime.now().isoformat(), + "usage_count": 0, + } + + return {"api_key": new_key, "key_id": key_id} + + +@app.delete("/admin/api-keys/{key_id}") +async def admin_delete_api_key(key_id: str, api_key: str = Depends(require_api_key)): + """API 키 삭제""" + if key_id in api_keys_store: + del api_keys_store[key_id] + return {"message": "API key deleted successfully"} + else: + raise HTTPException(status_code=404, detail="API key not found") + diff --git a/static/admin.css b/static/admin.css new file mode 100644 index 0000000..bfb3840 --- /dev/null +++ b/static/admin.css @@ -0,0 +1,364 @@ +/* AI Server Admin Dashboard CSS */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #f5f7fa; + color: #2c3e50; + line-height: 1.6; +} + +.admin-layout { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Header */ +.admin-header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 1rem 2rem; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); +} + +.header-content { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 1200px; + margin: 0 auto; +} + +.header-content h1 { + font-size: 1.8rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.header-info { + display: flex; + align-items: center; + gap: 1rem; +} + +.server-status { + display: flex; + align-items: center; + gap: 0.3rem; + padding: 0.3rem 0.8rem; + border-radius: 20px; + font-size: 0.9rem; + font-weight: 500; +} + +.server-status.online { + background: rgba(46, 204, 113, 0.2); + border: 1px solid rgba(46, 204, 113, 0.3); +} + +.server-status.offline { + background: rgba(231, 76, 60, 0.2); + border: 1px solid rgba(231, 76, 60, 0.3); +} + +.current-time { + font-size: 0.9rem; + opacity: 0.9; +} + +/* Main Content */ +.admin-main { + flex: 1; + padding: 2rem; + max-width: 1200px; + margin: 0 auto; + width: 100%; +} + +.dashboard-section { + margin-bottom: 3rem; +} + +.dashboard-section h2 { + color: #2c3e50; + margin-bottom: 1.5rem; + font-size: 1.4rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.5rem; + border-bottom: 2px solid #ecf0f1; + padding-bottom: 0.5rem; +} + +/* Status Grid */ +.status-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.status-card { + background: white; + border-radius: 12px; + padding: 1.5rem; + box-shadow: 0 4px 6px rgba(0,0,0,0.07); + border: 1px solid #e1e8ed; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.status-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(0,0,0,0.1); +} + +.card-header { + display: flex; + align-items: center; + gap: 0.8rem; + margin-bottom: 1rem; +} + +.card-header i { + font-size: 1.5rem; + color: #667eea; +} + +.card-header h3 { + font-size: 1.1rem; + font-weight: 600; + color: #2c3e50; +} + +.card-content { + text-align: center; +} + +.status-value { + font-size: 1.8rem; + font-weight: 700; + color: #27ae60; + margin-bottom: 0.5rem; +} + +.status-value.error { + color: #e74c3c; +} + +.status-value.warning { + color: #f39c12; +} + +.status-detail { + font-size: 0.9rem; + color: #7f8c8d; +} + +/* Tables */ +.models-container, .api-keys-container { + background: white; + border-radius: 12px; + padding: 1.5rem; + box-shadow: 0 4px 6px rgba(0,0,0,0.07); + border: 1px solid #e1e8ed; +} + +.models-header, .api-keys-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.models-table table { + width: 100%; + border-collapse: collapse; +} + +.models-table th, +.models-table td { + text-align: left; + padding: 1rem; + border-bottom: 1px solid #ecf0f1; +} + +.models-table th { + background: #f8f9fa; + font-weight: 600; + color: #2c3e50; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.models-table tr:hover { + background: #f8f9fa; +} + +.loading { + text-align: center; + color: #7f8c8d; + font-style: italic; + padding: 2rem; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 1.2rem; + border: none; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; +} + +.btn-primary { + background: #667eea; + color: white; +} + +.btn-primary:hover { + background: #5a6fd8; + transform: translateY(-1px); +} + +.btn-success { + background: #27ae60; + color: white; +} + +.btn-success:hover { + background: #229954; + transform: translateY(-1px); +} + +.btn-danger { + background: #e74c3c; + color: white; +} + +.btn-danger:hover { + background: #c0392b; + transform: translateY(-1px); +} + +.btn-small { + padding: 0.4rem 0.8rem; + font-size: 0.8rem; +} + +/* API Keys */ +.api-key-item { + background: #f8f9fa; + border: 1px solid #e1e8ed; + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.api-key-info { + flex: 1; +} + +.api-key-name { + font-weight: 600; + color: #2c3e50; + margin-bottom: 0.3rem; +} + +.api-key-value { + font-family: 'Monaco', 'Menlo', monospace; + font-size: 0.8rem; + color: #7f8c8d; + background: white; + padding: 0.3rem 0.6rem; + border-radius: 4px; + border: 1px solid #ddd; + margin-bottom: 0.3rem; +} + +.api-key-meta { + font-size: 0.8rem; + color: #95a5a6; +} + +.api-key-actions { + display: flex; + gap: 0.5rem; +} + +/* Status Badges */ +.status-badge { + display: inline-block; + padding: 0.2rem 0.6rem; + border-radius: 12px; + font-size: 0.8rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.status-badge.active { + background: #d5f4e6; + color: #27ae60; +} + +.status-badge.inactive { + background: #fadbd8; + color: #e74c3c; +} + +.status-badge.loading { + background: #fef9e7; + color: #f39c12; +} + +/* Responsive */ +@media (max-width: 768px) { + .admin-main { + padding: 1rem; + } + + .status-grid { + grid-template-columns: 1fr; + gap: 1rem; + } + + .header-content { + flex-direction: column; + gap: 1rem; + text-align: center; + } + + .models-table { + overflow-x: auto; + } + + .api-key-item { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .api-key-actions { + width: 100%; + justify-content: flex-end; + } +} diff --git a/static/admin.js b/static/admin.js new file mode 100644 index 0000000..fef9025 --- /dev/null +++ b/static/admin.js @@ -0,0 +1,273 @@ +// AI Server Admin Dashboard JavaScript + +class AdminDashboard { + constructor() { + this.apiKey = this.getApiKey(); + this.baseUrl = window.location.origin; + this.init(); + } + + getApiKey() { + // 테스트 모드에서는 기본 API 키 사용 + let apiKey = localStorage.getItem('ai_admin_api_key'); + if (!apiKey) { + // 테스트 모드 기본 키 + apiKey = 'test-admin-key-123'; + localStorage.setItem('ai_admin_api_key', apiKey); + + // 사용자에게 알림 + setTimeout(() => { + alert('테스트 모드입니다.\nAPI Key: test-admin-key-123'); + }, 1000); + } + return apiKey; + } + + async init() { + this.updateCurrentTime(); + setInterval(() => this.updateCurrentTime(), 1000); + + await this.loadSystemStatus(); + await this.loadModels(); + await this.loadApiKeys(); + + // Auto-refresh every 30 seconds + setInterval(() => { + this.loadSystemStatus(); + this.loadModels(); + }, 30000); + } + + updateCurrentTime() { + const now = new Date(); + document.getElementById('current-time').textContent = + now.toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } + + async apiRequest(endpoint, options = {}) { + const url = `${this.baseUrl}${endpoint}`; + const defaultOptions = { + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': this.apiKey + } + }; + + try { + const response = await fetch(url, { ...defaultOptions, ...options }); + if (!response.ok) { + if (response.status === 401) { + localStorage.removeItem('ai_admin_api_key'); + location.reload(); + return; + } + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return await response.json(); + } catch (error) { + console.error('API Request failed:', error); + throw error; + } + } + + async loadSystemStatus() { + try { + // Check AI Server status + const healthResponse = await this.apiRequest('/health'); + document.getElementById('server-status').textContent = 'Online'; + document.getElementById('server-status').className = 'status-value'; + + // Check Ollama status + try { + const ollamaResponse = await this.apiRequest('/admin/ollama/status'); + document.getElementById('ollama-status').textContent = + ollamaResponse.status === 'online' ? 'Online' : 'Offline'; + document.getElementById('ollama-status').className = + `status-value ${ollamaResponse.status === 'online' ? '' : 'error'}`; + } catch (error) { + document.getElementById('ollama-status').textContent = 'Offline'; + document.getElementById('ollama-status').className = 'status-value error'; + } + + // Load active model + try { + const modelResponse = await this.apiRequest('/admin/models/active'); + document.getElementById('active-model').textContent = + modelResponse.model || 'None'; + } catch (error) { + document.getElementById('active-model').textContent = 'Unknown'; + } + + // Load API call stats (placeholder) + document.getElementById('api-calls').textContent = '0'; + + } catch (error) { + console.error('Failed to load system status:', error); + document.getElementById('server-status').textContent = 'Error'; + document.getElementById('server-status').className = 'status-value error'; + } + } + + async loadModels() { + try { + const response = await this.apiRequest('/admin/models'); + const models = response.models || []; + + const tbody = document.getElementById('models-tbody'); + + if (models.length === 0) { + tbody.innerHTML = 'No models found'; + return; + } + + tbody.innerHTML = models.map(model => ` + + + ${model.name} + ${model.is_active ? 'Active' : ''} + + ${this.formatSize(model.size)} + + + ${model.status || 'Unknown'} + + + ${model.last_used ? new Date(model.last_used).toLocaleString('ko-KR') : 'Never'} + + + + + `).join(''); + + } catch (error) { + console.error('Failed to load models:', error); + document.getElementById('models-tbody').innerHTML = + 'Error loading models'; + } + } + + async loadApiKeys() { + try { + const response = await this.apiRequest('/admin/api-keys'); + const apiKeys = response.api_keys || []; + + const container = document.getElementById('api-keys-list'); + + if (apiKeys.length === 0) { + container.innerHTML = '
No API keys found
'; + return; + } + + container.innerHTML = apiKeys.map(key => ` +
+
+
${key.name || 'Unnamed Key'}
+
${this.maskApiKey(key.key)}
+
+ Created: ${new Date(key.created_at).toLocaleString('ko-KR')} | + Uses: ${key.usage_count || 0} +
+
+
+ +
+
+ `).join(''); + + } catch (error) { + console.error('Failed to load API keys:', error); + document.getElementById('api-keys-list').innerHTML = + '
Error loading API keys
'; + } + } + + formatSize(bytes) { + if (!bytes) return 'Unknown'; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; + } + + maskApiKey(key) { + if (!key) return 'Unknown'; + if (key.length <= 8) return key; + return key.substring(0, 4) + '...' + key.substring(key.length - 4); + } + + async refreshModels() { + document.getElementById('models-tbody').innerHTML = + 'Refreshing models...'; + await this.loadModels(); + } + + async testModel(modelName) { + try { + const response = await this.apiRequest('/admin/models/test', { + method: 'POST', + body: JSON.stringify({ model: modelName }) + }); + + alert(`Model test result:\n${response.result || 'Test completed successfully'}`); + } catch (error) { + alert(`Model test failed: ${error.message}`); + } + } + + async generateApiKey() { + const name = prompt('Enter a name for the new API key:'); + if (!name) return; + + try { + const response = await this.apiRequest('/admin/api-keys', { + method: 'POST', + body: JSON.stringify({ name }) + }); + + alert(`New API key created:\n${response.api_key}\n\nPlease save this key securely. It will not be shown again.`); + await this.loadApiKeys(); + } catch (error) { + alert(`Failed to generate API key: ${error.message}`); + } + } + + async deleteApiKey(keyId) { + if (!confirm('Are you sure you want to delete this API key?')) return; + + try { + await this.apiRequest(`/admin/api-keys/${keyId}`, { + method: 'DELETE' + }); + + await this.loadApiKeys(); + } catch (error) { + alert(`Failed to delete API key: ${error.message}`); + } + } +} + +// Global functions for HTML onclick handlers +let admin; + +function refreshModels() { + admin.refreshModels(); +} + +function generateApiKey() { + admin.generateApiKey(); +} + +// Initialize dashboard when page loads +document.addEventListener('DOMContentLoaded', () => { + admin = new AdminDashboard(); +}); diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..a06430b --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,127 @@ + + + + + + AI Server Admin Dashboard + + + + +
+ +
+
+

AI Server Admin

+
+ + Online + + +
+
+
+ + +
+ +
+

System Status

+
+
+
+ +

AI Server

+
+
+
Loading...
+
Port: {{ server_port }}
+
+
+ +
+
+ +

Ollama

+
+
+
Loading...
+
{{ ollama_host }}
+
+
+ +
+
+ +

Active Model

+
+
+
Loading...
+
Base Model
+
+
+ +
+
+ +

API Calls

+
+
+
Loading...
+
Today
+
+
+
+
+ + +
+

Model Management

+
+
+ +
+
+ + + + + + + + + + + + + + + +
Model NameSizeStatusLast UsedActions
Loading models...
+
+
+
+ + +
+

API Key Management

+
+
+ +
+
+
Loading API keys...
+
+
+
+
+
+ + + + + diff --git a/test_admin.py b/test_admin.py new file mode 100644 index 0000000..4fe1df4 --- /dev/null +++ b/test_admin.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +""" +AI Server Admin Dashboard Test Server +맥북프로에서 관리 페이지만 테스트하기 위한 간단한 서버 +""" + +import os +import secrets +import uuid +from datetime import datetime +from pathlib import Path +from typing import Optional + +from fastapi import FastAPI, Request, HTTPException, Depends, Header +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +import uvicorn + +# FastAPI 앱 초기화 +app = FastAPI(title="AI Server Admin Dashboard (Test Mode)") + +# 정적 파일 및 템플릿 설정 +app.mount("/static", StaticFiles(directory="static"), name="static") +templates = Jinja2Templates(directory="templates") + +# 테스트용 설정 +TEST_API_KEY = os.getenv("API_KEY", "test-admin-key-123") +TEST_SERVER_PORT = 28080 +TEST_OLLAMA_HOST = "http://localhost:11434" + +# 임시 데이터 저장소 +api_keys_store = { + "test-key-1": { + "name": "Test Key 1", + "key": "test-api-key-abcd1234", + "created_at": datetime.now().isoformat(), + "usage_count": 42, + }, + "test-key-2": { + "name": "Development Key", + "key": "dev-api-key-efgh5678", + "created_at": datetime.now().isoformat(), + "usage_count": 128, + } +} + +# 테스트용 모델 데이터 +test_models = [ + { + "name": "llama3.2:3b", + "size": 2048000000, # 2GB + "status": "ready", + "is_active": True, + "last_used": datetime.now().isoformat(), + }, + { + "name": "qwen2.5:7b", + "size": 4096000000, # 4GB + "status": "ready", + "is_active": False, + "last_used": "2024-12-20T10:30:00", + }, + { + "name": "gemma2:2b", + "size": 1536000000, # 1.5GB + "status": "inactive", + "is_active": False, + "last_used": "2024-12-19T15:45:00", + } +] + + +def require_api_key(x_api_key: Optional[str] = Header(None), api_key: Optional[str] = None): + """API 키 검증 (테스트 모드에서는 URL 파라미터도 허용)""" + # URL 파라미터로 API 키가 전달된 경우 + if api_key and api_key == TEST_API_KEY: + return api_key + # 헤더로 API 키가 전달된 경우 + if x_api_key and x_api_key == TEST_API_KEY: + return x_api_key + # 테스트 모드에서는 기본 허용 + return "test-mode" + + +@app.get("/", response_class=HTMLResponse) +async def root(): + """루트 페이지 - 관리 페이지로 리다이렉트""" + return HTMLResponse(""" + + AI Server Test + +

AI Server Admin Dashboard (Test Mode)

+

이것은 맥북프로에서 관리 페이지를 테스트하기 위한 서버입니다.

+

관리 페이지로 이동

+

API Key: test-admin-key-123

+ + + """) + + +@app.get("/health") +async def health_check(): + """헬스 체크""" + return {"status": "ok", "mode": "test", "timestamp": datetime.now().isoformat()} + + +# Admin Dashboard Routes +@app.get("/admin", response_class=HTMLResponse) +async def admin_dashboard(request: Request, api_key: str = Depends(require_api_key)): + """관리자 대시보드 페이지""" + return templates.TemplateResponse("admin.html", { + "request": request, + "server_port": TEST_SERVER_PORT, + "ollama_host": TEST_OLLAMA_HOST, + }) + + +@app.get("/admin/ollama/status") +async def admin_ollama_status(api_key: str = Depends(require_api_key)): + """Ollama 서버 상태 확인 (테스트 모드)""" + return {"status": "offline", "error": "Test mode - Ollama not available"} + + +@app.get("/admin/models") +async def admin_get_models(api_key: str = Depends(require_api_key)): + """설치된 모델 목록 조회 (테스트 데이터)""" + return {"models": test_models} + + +@app.get("/admin/models/active") +async def admin_get_active_model(api_key: str = Depends(require_api_key)): + """현재 활성 모델 조회""" + active_model = next((m for m in test_models if m["is_active"]), None) + return {"model": active_model["name"] if active_model else "None"} + + +@app.post("/admin/models/test") +async def admin_test_model(request: dict, api_key: str = Depends(require_api_key)): + """모델 테스트 (시뮬레이션)""" + model_name = request.get("model") + if not model_name: + raise HTTPException(status_code=400, detail="Model name is required") + + # 테스트 모드에서는 시뮬레이션 결과 반환 + return { + "result": f"Test mode simulation: Model '{model_name}' would respond with 'Hello! This is a test response from {model_name}.'" + } + + +@app.get("/admin/api-keys") +async def admin_get_api_keys(api_key: str = Depends(require_api_key)): + """API 키 목록 조회""" + keys = [] + for key_id, key_data in api_keys_store.items(): + keys.append({ + "id": key_id, + "name": key_data.get("name", "Unnamed"), + "key": key_data.get("key", ""), + "created_at": key_data.get("created_at", datetime.now().isoformat()), + "usage_count": key_data.get("usage_count", 0), + }) + + return {"api_keys": keys} + + +@app.post("/admin/api-keys") +async def admin_create_api_key(request: dict, api_key: str = Depends(require_api_key)): + """새 API 키 생성""" + name = request.get("name", "Unnamed Key") + new_key = secrets.token_urlsafe(32) + key_id = str(uuid.uuid4()) + + api_keys_store[key_id] = { + "name": name, + "key": new_key, + "created_at": datetime.now().isoformat(), + "usage_count": 0, + } + + return {"api_key": new_key, "key_id": key_id} + + +@app.delete("/admin/api-keys/{key_id}") +async def admin_delete_api_key(key_id: str, api_key: str = Depends(require_api_key)): + """API 키 삭제""" + if key_id in api_keys_store: + del api_keys_store[key_id] + return {"message": "API key deleted successfully"} + else: + raise HTTPException(status_code=404, detail="API key not found") + + +if __name__ == "__main__": + print("🚀 AI Server Admin Dashboard (Test Mode)") + print(f"📍 Server: http://localhost:{TEST_SERVER_PORT}") + print(f"🔧 Admin: http://localhost:{TEST_SERVER_PORT}/admin") + print(f"🔑 API Key: {TEST_API_KEY}") + print("=" * 50) + + uvicorn.run( + app, + host="0.0.0.0", + port=TEST_SERVER_PORT, + log_level="info" + )