diff --git a/server/main.py b/server/main.py index 046150f..f0a2a03 100644 --- a/server/main.py +++ b/server/main.py @@ -595,3 +595,154 @@ async def admin_delete_api_key(key_id: str, api_key: str = Depends(require_api_k else: raise HTTPException(status_code=404, detail="API key not found") + +# Phase 2: Advanced Model Management +@app.post("/admin/models/download") +async def admin_download_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: + # Ollama pull 명령 실행 + result = await ollama.pull_model(model_name) + return { + "success": True, + "message": f"Model '{model_name}' download started", + "details": result + } + except Exception as e: + return { + "success": False, + "error": f"Failed to download model: {str(e)}" + } + + +@app.delete("/admin/models/{model_name}") +async def admin_delete_model(model_name: str, api_key: str = Depends(require_api_key)): + """모델 삭제""" + try: + # Ollama 모델 삭제 + result = await ollama.delete_model(model_name) + return { + "success": True, + "message": f"Model '{model_name}' deleted successfully", + "details": result + } + except Exception as e: + return { + "success": False, + "error": f"Failed to delete model: {str(e)}" + } + + +@app.get("/admin/models/available") +async def admin_get_available_models(api_key: str = Depends(require_api_key)): + """다운로드 가능한 모델 목록""" + # 인기 있는 모델들 목록 (실제로는 Ollama 레지스트리에서 가져와야 함) + available_models = [ + { + "name": "llama3.2:1b", + "description": "Meta의 Llama 3.2 1B 모델 - 가벼운 작업용", + "size": "1.3GB", + "tags": ["chat", "lightweight"] + }, + { + "name": "llama3.2:3b", + "description": "Meta의 Llama 3.2 3B 모델 - 균형잡힌 성능", + "size": "2.0GB", + "tags": ["chat", "recommended"] + }, + { + "name": "qwen2.5:7b", + "description": "Alibaba의 Qwen 2.5 7B 모델 - 다국어 지원", + "size": "4.1GB", + "tags": ["chat", "multilingual"] + }, + { + "name": "gemma2:2b", + "description": "Google의 Gemma 2 2B 모델 - 효율적인 추론", + "size": "1.6GB", + "tags": ["chat", "efficient"] + }, + { + "name": "codellama:7b", + "description": "Meta의 Code Llama 7B - 코드 생성 특화", + "size": "3.8GB", + "tags": ["code", "programming"] + }, + { + "name": "mistral:7b", + "description": "Mistral AI의 7B 모델 - 고성능 추론", + "size": "4.1GB", + "tags": ["chat", "performance"] + } + ] + + return {"available_models": available_models} + + +# Phase 2: System Monitoring +@app.get("/admin/system/stats") +async def admin_get_system_stats(api_key: str = Depends(require_api_key)): + """시스템 리소스 사용률 조회""" + import psutil + import GPUtil + + try: + # CPU 사용률 + cpu_percent = psutil.cpu_percent(interval=1) + cpu_count = psutil.cpu_count() + + # 메모리 사용률 + memory = psutil.virtual_memory() + memory_percent = memory.percent + memory_used = memory.used // (1024**3) # GB + memory_total = memory.total // (1024**3) # GB + + # 디스크 사용률 + disk = psutil.disk_usage('/') + disk_percent = (disk.used / disk.total) * 100 + disk_used = disk.used // (1024**3) # GB + disk_total = disk.total // (1024**3) # GB + + # GPU 사용률 (NVIDIA GPU가 있는 경우) + gpu_stats = [] + try: + gpus = GPUtil.getGPUs() + for gpu in gpus: + gpu_stats.append({ + "name": gpu.name, + "load": gpu.load * 100, + "memory_used": gpu.memoryUsed, + "memory_total": gpu.memoryTotal, + "temperature": gpu.temperature + }) + except: + gpu_stats = [] + + return { + "cpu": { + "usage_percent": cpu_percent, + "core_count": cpu_count + }, + "memory": { + "usage_percent": memory_percent, + "used_gb": memory_used, + "total_gb": memory_total + }, + "disk": { + "usage_percent": disk_percent, + "used_gb": disk_used, + "total_gb": disk_total + }, + "gpu": gpu_stats, + "timestamp": datetime.now().isoformat() + } + except Exception as e: + return { + "error": f"Failed to get system stats: {str(e)}", + "timestamp": datetime.now().isoformat() + } + diff --git a/static/admin.css b/static/admin.css index bfb3840..9e71ee3 100644 --- a/static/admin.css +++ b/static/admin.css @@ -330,6 +330,255 @@ body { color: #f39c12; } +/* Phase 2: System Monitoring Styles */ +.monitoring-container { + background: white; + border-radius: 12px; + padding: 1.5rem; + box-shadow: 0 4px 6px rgba(0,0,0,0.07); + border: 1px solid #e1e8ed; +} + +.monitoring-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1.5rem; +} + +.monitor-card { + background: #f8f9fa; + border-radius: 8px; + padding: 1.5rem; + text-align: center; + border: 1px solid #e9ecef; +} + +.monitor-card .card-header { + margin-bottom: 1rem; +} + +.progress-circle { + position: relative; + width: 80px; + height: 80px; + margin: 0 auto 1rem; + border-radius: 50%; + background: conic-gradient(#667eea 0deg, #e9ecef 0deg); + display: flex; + align-items: center; + justify-content: center; +} + +.progress-circle::before { + content: ''; + position: absolute; + width: 60px; + height: 60px; + border-radius: 50%; + background: white; +} + +.progress-text { + position: relative; + z-index: 1; + font-size: 0.9rem; + font-weight: 600; + color: #2c3e50; +} + +.monitor-details { + font-size: 0.8rem; + color: #7f8c8d; +} + +/* Progress circle colors */ +.progress-circle.low { + background: conic-gradient(#27ae60 var(--progress, 0deg), #e9ecef var(--progress, 0deg)); +} + +.progress-circle.medium { + background: conic-gradient(#f39c12 var(--progress, 0deg), #e9ecef var(--progress, 0deg)); +} + +.progress-circle.high { + background: conic-gradient(#e74c3c var(--progress, 0deg), #e9ecef var(--progress, 0deg)); +} + +/* Modal Styles */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.5); + backdrop-filter: blur(4px); +} + +.modal-content { + background-color: white; + margin: 5% auto; + padding: 0; + border-radius: 12px; + width: 90%; + max-width: 600px; + box-shadow: 0 10px 30px rgba(0,0,0,0.3); + animation: modalSlideIn 0.3s ease; +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-50px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 1px solid #e1e8ed; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-radius: 12px 12px 0 0; +} + +.modal-header h3 { + margin: 0; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.close-btn { + background: none; + border: none; + font-size: 1.5rem; + color: white; + cursor: pointer; + padding: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: background-color 0.2s ease; +} + +.close-btn:hover { + background-color: rgba(255,255,255,0.2); +} + +.modal-body { + padding: 1.5rem; +} + +.modal-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid #e1e8ed; +} + +/* Available Models List */ +.available-model-item { + background: #f8f9fa; + border: 1px solid #e1e8ed; + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + transition: border-color 0.2s ease; +} + +.available-model-item:hover { + border-color: #667eea; +} + +.model-info { + flex: 1; +} + +.model-name { + font-weight: 600; + color: #2c3e50; + margin-bottom: 0.3rem; +} + +.model-description { + font-size: 0.9rem; + color: #7f8c8d; + margin-bottom: 0.5rem; +} + +.model-tags { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.model-tag { + background: #667eea; + color: white; + padding: 0.2rem 0.5rem; + border-radius: 12px; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.model-tag.code { + background: #e74c3c; +} + +.model-tag.lightweight { + background: #27ae60; +} + +.model-tag.recommended { + background: #f39c12; +} + +.model-size { + font-size: 0.9rem; + color: #95a5a6; + margin-top: 0.5rem; +} + +.model-delete-info { + background: #fadbd8; + border: 1px solid #f1948a; + border-radius: 8px; + padding: 1rem; + margin: 1rem 0; +} + +.model-delete-info strong { + color: #e74c3c; +} + +/* Enhanced Models Table */ +.models-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + flex-wrap: wrap; + gap: 1rem; +} + /* Responsive */ @media (max-width: 768px) { .admin-main { @@ -341,6 +590,11 @@ body { gap: 1rem; } + .monitoring-grid { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1rem; + } + .header-content { flex-direction: column; gap: 1rem; @@ -351,6 +605,11 @@ body { overflow-x: auto; } + .models-header { + flex-direction: column; + align-items: stretch; + } + .api-key-item { flex-direction: column; align-items: flex-start; @@ -361,4 +620,19 @@ body { width: 100%; justify-content: flex-end; } + + .available-model-item { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .modal-content { + width: 95%; + margin: 2% auto; + } + + .modal-actions { + flex-direction: column; + } } diff --git a/static/admin.js b/static/admin.js index fef9025..e345b1f 100644 --- a/static/admin.js +++ b/static/admin.js @@ -30,11 +30,13 @@ class AdminDashboard { await this.loadSystemStatus(); await this.loadModels(); await this.loadApiKeys(); + await this.loadSystemStats(); // Phase 2 // Auto-refresh every 30 seconds setInterval(() => { this.loadSystemStatus(); this.loadModels(); + this.loadSystemStats(); // Phase 2 }, 30000); } @@ -144,6 +146,9 @@ class AdminDashboard { + `).join(''); @@ -254,6 +259,161 @@ class AdminDashboard { alert(`Failed to delete API key: ${error.message}`); } } + + // Phase 2: System Monitoring + async loadSystemStats() { + try { + const response = await this.apiRequest('/admin/system/stats'); + + // Update CPU + this.updateProgressCircle('cpu-progress', response.cpu.usage_percent); + document.getElementById('cpu-text').textContent = `${response.cpu.usage_percent}%`; + document.getElementById('cpu-cores').textContent = `${response.cpu.core_count} cores`; + + // Update Memory + this.updateProgressCircle('memory-progress', response.memory.usage_percent); + document.getElementById('memory-text').textContent = `${response.memory.usage_percent}%`; + document.getElementById('memory-details').textContent = + `${response.memory.used_gb} / ${response.memory.total_gb} GB`; + + // Update Disk + this.updateProgressCircle('disk-progress', response.disk.usage_percent); + document.getElementById('disk-text').textContent = `${response.disk.usage_percent}%`; + document.getElementById('disk-details').textContent = + `${response.disk.used_gb} / ${response.disk.total_gb} GB`; + + // Update GPU + if (response.gpu && response.gpu.length > 0) { + const gpu = response.gpu[0]; + this.updateProgressCircle('gpu-progress', gpu.load); + document.getElementById('gpu-text').textContent = `${gpu.load}%`; + document.getElementById('gpu-details').textContent = + `${gpu.name} - ${gpu.temperature}°C`; + } else { + document.getElementById('gpu-text').textContent = '--'; + document.getElementById('gpu-details').textContent = 'No GPU detected'; + } + + } catch (error) { + console.error('Failed to load system stats:', error); + } + } + + updateProgressCircle(elementId, percentage) { + const element = document.getElementById(elementId); + const degrees = (percentage / 100) * 360; + + // Remove existing color classes + element.classList.remove('low', 'medium', 'high'); + + // Add appropriate color class + if (percentage < 50) { + element.classList.add('low'); + } else if (percentage < 80) { + element.classList.add('medium'); + } else { + element.classList.add('high'); + } + + // Update CSS custom property for progress + element.style.setProperty('--progress', `${degrees}deg`); + } + + // Phase 2: Model Download + async openModelDownload() { + try { + const response = await this.apiRequest('/admin/models/available'); + const models = response.available_models || []; + + const container = document.getElementById('available-models-list'); + + if (models.length === 0) { + container.innerHTML = '
No models available
'; + } else { + container.innerHTML = models.map(model => ` +
+
+
${model.name}
+
${model.description}
+
+ ${model.tags.map(tag => `${tag}`).join('')} +
+
Size: ${model.size}
+
+ +
+ `).join(''); + } + + this.openModal('model-download-modal'); + + } catch (error) { + console.error('Failed to load available models:', error); + alert('Failed to load available models'); + } + } + + async downloadModel(modelName) { + try { + const response = await this.apiRequest('/admin/models/download', { + method: 'POST', + body: JSON.stringify({ model: modelName }) + }); + + if (response.success) { + alert(`Download started: ${response.message}`); + this.closeModal('model-download-modal'); + // Refresh models list after a short delay + setTimeout(() => this.loadModels(), 2000); + } else { + alert(`Download failed: ${response.error}`); + } + + } catch (error) { + alert(`Download failed: ${error.message}`); + } + } + + // Phase 2: Model Delete + confirmDeleteModel(modelName) { + document.getElementById('delete-model-name').textContent = modelName; + + // Set up delete confirmation + const confirmBtn = document.getElementById('confirm-delete-btn'); + confirmBtn.onclick = () => this.deleteModel(modelName); + + this.openModal('model-delete-modal'); + } + + async deleteModel(modelName) { + try { + const response = await this.apiRequest(`/admin/models/${modelName}`, { + method: 'DELETE' + }); + + if (response.success) { + alert(`Model deleted: ${response.message}`); + this.closeModal('model-delete-modal'); + await this.loadModels(); + } else { + alert(`Delete failed: ${response.error}`); + } + + } catch (error) { + alert(`Delete failed: ${error.message}`); + } + } + + // Modal management + openModal(modalId) { + document.getElementById(modalId).style.display = 'block'; + } + + closeModal(modalId) { + document.getElementById(modalId).style.display = 'none'; + } } // Global functions for HTML onclick handlers @@ -267,6 +427,14 @@ function generateApiKey() { admin.generateApiKey(); } +function openModelDownload() { + admin.openModelDownload(); +} + +function closeModal(modalId) { + admin.closeModal(modalId); +} + // Initialize dashboard when page loads document.addEventListener('DOMContentLoaded', () => { admin = new AdminDashboard(); diff --git a/templates/admin.html b/templates/admin.html index a06430b..066d855 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -82,6 +82,9 @@ +
@@ -104,6 +107,74 @@ + +
+

System Monitoring

+
+
+
+
+ +

CPU Usage

+
+
+
+ -- +
+
+ -- cores +
+
+
+ +
+
+ +

Memory Usage

+
+
+
+ -- +
+
+ -- / -- GB +
+
+
+ +
+
+ +

Disk Usage

+
+
+
+ -- +
+
+ -- / -- GB +
+
+
+ +
+
+ +

GPU Status

+
+
+
+ -- +
+
+ No GPU detected +
+
+
+
+
+
+

API Key Management

@@ -121,6 +192,46 @@ + + + + + + diff --git a/test_admin.py b/test_admin.py index 4fe1df4..26d0436 100644 --- a/test_admin.py +++ b/test_admin.py @@ -191,6 +191,116 @@ async def admin_delete_api_key(key_id: str, api_key: str = Depends(require_api_k raise HTTPException(status_code=404, detail="API key not found") +# Phase 2: Advanced Model Management (Test Mode) +@app.post("/admin/models/download") +async def admin_download_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 { + "success": True, + "message": f"Test mode: Model '{model_name}' download simulation started", + "details": f"In real mode, this would download {model_name} from Ollama registry" + } + + +@app.delete("/admin/models/{model_name}") +async def admin_delete_model(model_name: str, api_key: str = Depends(require_api_key)): + """모델 삭제 (테스트 모드)""" + # 테스트 데이터에서 모델 제거 + global test_models + test_models = [m for m in test_models if m["name"] != model_name] + + return { + "success": True, + "message": f"Test mode: Model '{model_name}' deleted from test data", + "details": f"In real mode, this would delete {model_name} from Ollama" + } + + +@app.get("/admin/models/available") +async def admin_get_available_models(api_key: str = Depends(require_api_key)): + """다운로드 가능한 모델 목록""" + available_models = [ + { + "name": "llama3.2:1b", + "description": "Meta의 Llama 3.2 1B 모델 - 가벼운 작업용", + "size": "1.3GB", + "tags": ["chat", "lightweight"] + }, + { + "name": "llama3.2:3b", + "description": "Meta의 Llama 3.2 3B 모델 - 균형잡힌 성능", + "size": "2.0GB", + "tags": ["chat", "recommended"] + }, + { + "name": "qwen2.5:7b", + "description": "Alibaba의 Qwen 2.5 7B 모델 - 다국어 지원", + "size": "4.1GB", + "tags": ["chat", "multilingual"] + }, + { + "name": "gemma2:2b", + "description": "Google의 Gemma 2 2B 모델 - 효율적인 추론", + "size": "1.6GB", + "tags": ["chat", "efficient"] + }, + { + "name": "codellama:7b", + "description": "Meta의 Code Llama 7B - 코드 생성 특화", + "size": "3.8GB", + "tags": ["code", "programming"] + }, + { + "name": "mistral:7b", + "description": "Mistral AI의 7B 모델 - 고성능 추론", + "size": "4.1GB", + "tags": ["chat", "performance"] + } + ] + + return {"available_models": available_models} + + +# Phase 2: System Monitoring (Test Mode) +@app.get("/admin/system/stats") +async def admin_get_system_stats(api_key: str = Depends(require_api_key)): + """시스템 리소스 사용률 조회 (테스트 데이터)""" + import random + + # 테스트용 랜덤 데이터 생성 + return { + "cpu": { + "usage_percent": round(random.uniform(10, 80), 1), + "core_count": 8 + }, + "memory": { + "usage_percent": round(random.uniform(30, 90), 1), + "used_gb": round(random.uniform(4, 12), 1), + "total_gb": 16 + }, + "disk": { + "usage_percent": round(random.uniform(20, 70), 1), + "used_gb": round(random.uniform(50, 200), 1), + "total_gb": 500 + }, + "gpu": [ + { + "name": "Test GPU (Simulated)", + "load": round(random.uniform(0, 100), 1), + "memory_used": round(random.uniform(1000, 8000)), + "memory_total": 8192, + "temperature": round(random.uniform(45, 75)) + } + ], + "timestamp": datetime.now().isoformat() + } + + if __name__ == "__main__": print("🚀 AI Server Admin Dashboard (Test Mode)") print(f"📍 Server: http://localhost:{TEST_SERVER_PORT}")