System Status
+AI Server
+Ollama
+Active Model
+API Calls
+Model Management
+| Model Name | +Size | +Status | +Last Used | +Actions | +
|---|---|---|---|---|
| Loading models... | +||||
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 = '
| Model Name | +Size | +Status | +Last Used | +Actions | +
|---|---|---|---|---|
| Loading models... | +||||
이것은 맥북프로에서 관리 페이지를 테스트하기 위한 서버입니다.
+ +API Key: test-admin-key-123