feat: AI 서버 관리 페이지 Phase 1 구현

- 웹 기반 관리 대시보드 추가 (/admin)
- 시스템 상태 모니터링 (AI 서버, Ollama, 활성 모델, API 호출)
- 모델 관리 기능 (목록 조회, 테스트, 새로고침)
- API 키 관리 시스템 (생성, 조회, 삭제)
- 반응형 UI/UX 디자인 (모바일 지원)
- 테스트 모드 서버 (test_admin.py) 추가
- 보안: API 키 기반 인증, 키 마스킹
- 실시간 업데이트 (30초 자동 새로고침)

구현 파일:
- templates/admin.html: 관리 페이지 HTML
- static/admin.css: 관리 페이지 스타일
- static/admin.js: 관리 페이지 JavaScript
- server/main.py: 관리 API 엔드포인트 추가
- test_admin.py: 맥북프로 테스트용 서버
- README.md: 관리 페이지 문서 업데이트
This commit is contained in:
Hyungi Ahn
2025-08-18 13:33:39 +09:00
parent cb009f7393
commit e102ce6db9
6 changed files with 1142 additions and 4 deletions

View File

@@ -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) 추가 1) Ollama API를 감싸는 경량 서버(FastAPI) 구현 완료
2) 표준화된 엔드포인트(`/v1/chat/completions`, `/v1/completions`) 제공 2) 표준화된 엔드포인트(`/v1/chat/completions`, `/v1/completions`) 제공
3) 헬스체크/모델 선택/리밋/로깅 옵션 제공 3) 헬스체크/모델 선택/리밋/로깅 옵션 제공
4) 🚧 웹 기반 관리 페이지 구현 중 (Phase 1)
우선 본 문서로 설치/선택 가이드를 정리했으며, 다음 단계에서 서버 스켈레톤과 샘플 클라이언트를 추가할 예정입니다. 우선 본 문서로 설치/선택 가이드를 정리했으며, 현재 관리 페이지와 고급 기능들을 단계적으로 추가하고 있습니다.

View File

@@ -471,3 +471,127 @@ async def chat_page(request: Request):
"api_key": os.getenv("API_KEY", "") "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")

364
static/admin.css Normal file
View File

@@ -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;
}
}

273
static/admin.js Normal file
View File

@@ -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 = '<tr><td colspan="5" class="loading">No models found</td></tr>';
return;
}
tbody.innerHTML = models.map(model => `
<tr>
<td>
<strong>${model.name}</strong>
${model.is_active ? '<span class="status-badge active">Active</span>' : ''}
</td>
<td>${this.formatSize(model.size)}</td>
<td>
<span class="status-badge ${model.status === 'ready' ? 'active' : 'inactive'}">
${model.status || 'Unknown'}
</span>
</td>
<td>${model.last_used ? new Date(model.last_used).toLocaleString('ko-KR') : 'Never'}</td>
<td>
<button class="btn btn-small btn-primary" onclick="admin.testModel('${model.name}')">
<i class="fas fa-play"></i> Test
</button>
</td>
</tr>
`).join('');
} catch (error) {
console.error('Failed to load models:', error);
document.getElementById('models-tbody').innerHTML =
'<tr><td colspan="5" class="loading">Error loading models</td></tr>';
}
}
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 = '<div class="loading">No API keys found</div>';
return;
}
container.innerHTML = apiKeys.map(key => `
<div class="api-key-item">
<div class="api-key-info">
<div class="api-key-name">${key.name || 'Unnamed Key'}</div>
<div class="api-key-value">${this.maskApiKey(key.key)}</div>
<div class="api-key-meta">
Created: ${new Date(key.created_at).toLocaleString('ko-KR')} |
Uses: ${key.usage_count || 0}
</div>
</div>
<div class="api-key-actions">
<button class="btn btn-small btn-danger" onclick="admin.deleteApiKey('${key.id}')">
<i class="fas fa-trash"></i> Delete
</button>
</div>
</div>
`).join('');
} catch (error) {
console.error('Failed to load API keys:', error);
document.getElementById('api-keys-list').innerHTML =
'<div class="loading">Error loading API keys</div>';
}
}
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 =
'<tr><td colspan="5" class="loading">Refreshing models...</td></tr>';
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();
});

127
templates/admin.html Normal file
View File

@@ -0,0 +1,127 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Server Admin Dashboard</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link href="/static/admin.css" rel="stylesheet">
</head>
<body>
<div class="admin-layout">
<!-- Header -->
<header class="admin-header">
<div class="header-content">
<h1><i class="fas fa-robot"></i> AI Server Admin</h1>
<div class="header-info">
<span class="server-status online">
<i class="fas fa-circle"></i> Online
</span>
<span class="current-time" id="current-time"></span>
</div>
</div>
</header>
<!-- Main Content -->
<main class="admin-main">
<!-- System Status Dashboard -->
<section class="dashboard-section">
<h2><i class="fas fa-tachometer-alt"></i> System Status</h2>
<div class="status-grid">
<div class="status-card">
<div class="card-header">
<i class="fas fa-server"></i>
<h3>AI Server</h3>
</div>
<div class="card-content">
<div class="status-value" id="server-status">Loading...</div>
<div class="status-detail">Port: {{ server_port }}</div>
</div>
</div>
<div class="status-card">
<div class="card-header">
<i class="fas fa-brain"></i>
<h3>Ollama</h3>
</div>
<div class="card-content">
<div class="status-value" id="ollama-status">Loading...</div>
<div class="status-detail" id="ollama-host">{{ ollama_host }}</div>
</div>
</div>
<div class="status-card">
<div class="card-header">
<i class="fas fa-microchip"></i>
<h3>Active Model</h3>
</div>
<div class="card-content">
<div class="status-value" id="active-model">Loading...</div>
<div class="status-detail">Base Model</div>
</div>
</div>
<div class="status-card">
<div class="card-header">
<i class="fas fa-chart-line"></i>
<h3>API Calls</h3>
</div>
<div class="card-content">
<div class="status-value" id="api-calls">Loading...</div>
<div class="status-detail">Today</div>
</div>
</div>
</div>
</section>
<!-- Model Management -->
<section class="dashboard-section">
<h2><i class="fas fa-cogs"></i> Model Management</h2>
<div class="models-container">
<div class="models-header">
<button class="btn btn-primary" onclick="refreshModels()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
<div class="models-table">
<table>
<thead>
<tr>
<th>Model Name</th>
<th>Size</th>
<th>Status</th>
<th>Last Used</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="models-tbody">
<tr>
<td colspan="5" class="loading">Loading models...</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- API Key Management -->
<section class="dashboard-section">
<h2><i class="fas fa-key"></i> API Key Management</h2>
<div class="api-keys-container">
<div class="api-keys-header">
<button class="btn btn-success" onclick="generateApiKey()">
<i class="fas fa-plus"></i> Generate New Key
</button>
</div>
<div class="api-keys-list" id="api-keys-list">
<div class="loading">Loading API keys...</div>
</div>
</div>
</section>
</main>
</div>
<!-- Scripts -->
<script src="/static/admin.js"></script>
</body>
</html>

206
test_admin.py Normal file
View File

@@ -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("""
<html>
<head><title>AI Server Test</title></head>
<body>
<h1>AI Server Admin Dashboard (Test Mode)</h1>
<p>이것은 맥북프로에서 관리 페이지를 테스트하기 위한 서버입니다.</p>
<p><a href="/admin">관리 페이지로 이동</a></p>
<p><strong>API Key:</strong> <code>test-admin-key-123</code></p>
</body>
</html>
""")
@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"
)