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

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();
});