feat: 백그라운드 AI 서비스 및 실시간 대시보드 추가

- 백그라운드에서 AI 모델을 항상 로딩된 상태로 유지
- 실시간 모델 상태 모니터링 대시보드 웹페이지 구현
- 시스템 성능 지표 수집 및 시각화
- AI 모델 재시작, 캐시 정리 등 관리 기능
- 작업 큐 시스템으로 처리 효율성 향상
- psutil 의존성 추가로 시스템 모니터링 강화
This commit is contained in:
hyungi
2025-07-25 06:40:52 +09:00
parent 5db20e2943
commit 18e53355a0
4 changed files with 1271 additions and 0 deletions

View File

@@ -30,3 +30,6 @@ pandas>=2.0.0
# 추가 도구
requests>=2.31.0
pathlib2>=2.3.7
# 시스템 모니터링 및 백그라운드 서비스
psutil>=5.9.0

View File

@@ -0,0 +1,283 @@
#!/usr/bin/env python3
"""
백그라운드 AI 모델 서비스 및 모니터링 시스템
실시간 모델 상태 추적 및 성능 지표 수집
"""
import asyncio
import time
import psutil
import threading
from datetime import datetime, timedelta
from dataclasses import dataclass, asdict
from typing import Dict, List, Optional
from pathlib import Path
import json
import logging
from integrated_translation_system import IntegratedTranslationSystem
@dataclass
class ModelStatus:
"""개별 모델 상태 정보"""
name: str
status: str # "loading", "ready", "error", "unloaded"
load_time: Optional[float] = None
memory_usage_mb: float = 0.0
last_used: Optional[datetime] = None
error_message: Optional[str] = None
total_processed: int = 0
@dataclass
class SystemMetrics:
"""전체 시스템 성능 지표"""
total_memory_usage_mb: float
cpu_usage_percent: float
active_jobs: int
queued_jobs: int
completed_jobs_today: int
average_processing_time: float
uptime_seconds: float
class BackgroundAIService:
"""백그라운드 AI 모델 서비스 관리자"""
def __init__(self):
self.models_status: Dict[str, ModelStatus] = {}
self.translation_system: Optional[IntegratedTranslationSystem] = None
self.start_time = datetime.now()
self.job_queue = asyncio.Queue()
self.active_jobs = {}
self.completed_jobs = []
self.metrics_history = []
# 로깅 설정
self.logger = logging.getLogger(__name__)
self.logger.setLevel(logging.INFO)
# 모델 상태 초기화
self._initialize_model_status()
# 백그라운드 워커 및 모니터링 시작
self.worker_task = None
self.monitoring_task = None
def _initialize_model_status(self):
"""모델 상태 초기화"""
model_names = ["NLLB 번역", "KoBART 요약"]
for name in model_names:
self.models_status[name] = ModelStatus(
name=name,
status="unloaded"
)
async def start_service(self):
"""백그라운드 서비스 시작"""
self.logger.info("🚀 백그라운드 AI 서비스 시작")
# 모델 로딩 시작
asyncio.create_task(self._load_models())
# 백그라운드 워커 시작
self.worker_task = asyncio.create_task(self._process_job_queue())
# 모니터링 시작
self.monitoring_task = asyncio.create_task(self._collect_metrics())
self.logger.info("✅ 백그라운드 서비스 준비 완료")
async def _load_models(self):
"""AI 모델들을 순차적으로 로딩"""
try:
# NLLB 모델 로딩 시작
self.models_status["NLLB 번역"].status = "loading"
load_start = time.time()
self.translation_system = IntegratedTranslationSystem()
# 실제 모델 로딩
success = await asyncio.to_thread(self.translation_system.load_models)
if success:
load_time = time.time() - load_start
# 각 모델 상태 업데이트
self.models_status["NLLB 번역"].status = "ready"
self.models_status["NLLB 번역"].load_time = load_time
self.models_status["KoBART 요약"].status = "ready"
self.models_status["KoBART 요약"].load_time = load_time
self.logger.info(f"✅ 모든 AI 모델 로딩 완료 ({load_time:.1f}초)")
else:
# 로딩 실패
for model_name in self.models_status.keys():
self.models_status[model_name].status = "error"
self.models_status[model_name].error_message = "모델 로딩 실패"
self.logger.error("❌ AI 모델 로딩 실패")
except Exception as e:
self.logger.error(f"❌ 모델 로딩 중 오류: {e}")
for model_name in self.models_status.keys():
self.models_status[model_name].status = "error"
self.models_status[model_name].error_message = str(e)
def _get_memory_usage(self) -> float:
"""현재 프로세스의 메모리 사용량 반환 (MB)"""
try:
process = psutil.Process()
return process.memory_info().rss / 1024 / 1024 # bytes to MB
except:
return 0.0
async def _collect_metrics(self):
"""주기적으로 시스템 메트릭 수집"""
while True:
try:
# 메모리 사용량 업데이트
total_memory = self._get_memory_usage()
# 각 모델별 메모리 사용량 추정 (실제로는 더 정교한 측정 필요)
if self.models_status["NLLB 번역"].status == "ready":
self.models_status["NLLB 번역"].memory_usage_mb = total_memory * 0.6
if self.models_status["KoBART 요약"].status == "ready":
self.models_status["KoBART 요약"].memory_usage_mb = total_memory * 0.4
# 전체 시스템 메트릭 수집
metrics = SystemMetrics(
total_memory_usage_mb=total_memory,
cpu_usage_percent=psutil.cpu_percent(),
active_jobs=len(self.active_jobs),
queued_jobs=self.job_queue.qsize(),
completed_jobs_today=len([
job for job in self.completed_jobs
if job.get('completed_at', datetime.min).date() == datetime.now().date()
]),
average_processing_time=self._calculate_average_processing_time(),
uptime_seconds=(datetime.now() - self.start_time).total_seconds()
)
# 메트릭 히스토리에 추가 (최근 100개만 유지)
self.metrics_history.append({
'timestamp': datetime.now(),
'metrics': metrics
})
if len(self.metrics_history) > 100:
self.metrics_history.pop(0)
await asyncio.sleep(5) # 5초마다 수집
except Exception as e:
self.logger.error(f"메트릭 수집 오류: {e}")
await asyncio.sleep(10)
def _calculate_average_processing_time(self) -> float:
"""최근 처리된 작업들의 평균 처리 시간 계산"""
recent_jobs = [
job for job in self.completed_jobs[-20:] # 최근 20개
if 'processing_time' in job
]
if not recent_jobs:
return 0.0
return sum(job['processing_time'] for job in recent_jobs) / len(recent_jobs)
async def _process_job_queue(self):
"""작업 큐 처리 워커"""
while True:
try:
# 큐에서 작업 가져오기
job = await self.job_queue.get()
job_id = job['job_id']
# 활성 작업에 추가
self.active_jobs[job_id] = {
'start_time': datetime.now(),
'job_data': job
}
# 실제 AI 처리
await self._process_single_job(job)
# 완료된 작업으로 이동
processing_time = (datetime.now() - self.active_jobs[job_id]['start_time']).total_seconds()
self.completed_jobs.append({
'job_id': job_id,
'completed_at': datetime.now(),
'processing_time': processing_time
})
# 활성 작업에서 제거
del self.active_jobs[job_id]
# 모델 사용 시간 업데이트
for model_status in self.models_status.values():
if model_status.status == "ready":
model_status.last_used = datetime.now()
model_status.total_processed += 1
except Exception as e:
self.logger.error(f"작업 처리 오류: {e}")
if job_id in self.active_jobs:
del self.active_jobs[job_id]
async def _process_single_job(self, job):
"""개별 작업 처리"""
# 기존 번역 시스템 로직 사용
if self.translation_system and self.models_status["NLLB 번역"].status == "ready":
result = await asyncio.to_thread(
self.translation_system.process_document,
job['file_path']
)
return result
else:
raise Exception("AI 모델이 준비되지 않음")
async def add_job(self, job_data: Dict):
"""새 작업을 큐에 추가"""
await self.job_queue.put(job_data)
def get_dashboard_data(self) -> Dict:
"""대시보드용 데이터 반환"""
current_metrics = self.metrics_history[-1]['metrics'] if self.metrics_history else None
return {
'models_status': {name: asdict(status) for name, status in self.models_status.items()},
'current_metrics': asdict(current_metrics) if current_metrics else None,
'recent_metrics': [
{
'timestamp': entry['timestamp'].isoformat(),
'metrics': asdict(entry['metrics'])
}
for entry in self.metrics_history[-20:] # 최근 20개
],
'active_jobs': len(self.active_jobs),
'completed_today': len([
job for job in self.completed_jobs
if job.get('completed_at', datetime.min).date() == datetime.now().date()
])
}
async def restart_models(self):
"""모델 재시작"""
self.logger.info("🔄 AI 모델 재시작 중...")
# 모든 모델 상태를 재로딩으로 설정
for model_status in self.models_status.values():
model_status.status = "loading"
model_status.error_message = None
# 기존 시스템 정리
if self.translation_system:
del self.translation_system
# 모델 재로딩
await self._load_models()
# 전역 서비스 인스턴스
ai_service = BackgroundAIService()

View File

@@ -0,0 +1,400 @@
#!/usr/bin/env python3
"""
Mac Mini (192.168.1.122) FastAPI + DS1525+ (192.168.1.227) 연동
백그라운드 AI 서비스 및 대시보드 통합 버전
"""
from fastapi import FastAPI, File, UploadFile, HTTPException, BackgroundTasks, Request
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
import asyncio
import json
import uuid
from pathlib import Path
from typing import Dict, List, Optional
import time
import shutil
from dataclasses import dataclass, asdict
import aiofiles
from datetime import datetime
import subprocess
import logging
# 백그라운드 AI 서비스 임포트
from background_ai_service import ai_service
# 실제 네트워크 설정
MAC_MINI_IP = "192.168.1.122"
NAS_IP = "192.168.1.227"
# NAS 마운트 경로 (Finder에서 연결 시 생성되는 경로)
NAS_MOUNT_POINT = Path("/Volumes/DS1525+")
DOCUMENT_UPLOAD_BASE = NAS_MOUNT_POINT / "Document-upload"
# 세부 경로들
NAS_ORIGINALS_PATH = DOCUMENT_UPLOAD_BASE / "originals"
NAS_TRANSLATED_PATH = DOCUMENT_UPLOAD_BASE / "translated"
NAS_STATIC_HOSTING_PATH = DOCUMENT_UPLOAD_BASE / "static-hosting"
NAS_METADATA_PATH = DOCUMENT_UPLOAD_BASE / "metadata"
# 로컬 작업 디렉토리
LOCAL_WORK_PATH = Path.home() / "Scripts" / "nllb-translation-system"
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
app = FastAPI(
title="AI 번역 시스템 with 대시보드",
description=f"Mac Mini ({MAC_MINI_IP}) + DS1525+ ({NAS_IP}) 연동 + 실시간 모니터링",
version="2.0.0"
)
# 정적 파일 및 템플릿
app.mount("/static", StaticFiles(directory=str(LOCAL_WORK_PATH / "static")), name="static")
templates = Jinja2Templates(directory=str(LOCAL_WORK_PATH / "templates"))
# 작업 상태 관리 (백그라운드 서비스와 연동)
processing_jobs: Dict[str, Dict] = {}
def check_nas_connection():
"""NAS 연결 상태 실시간 확인"""
try:
# 1. ping 테스트
ping_result = subprocess.run(
["ping", "-c", "1", "-W", "3000", NAS_IP],
capture_output=True,
timeout=5
)
if ping_result.returncode != 0:
return {"status": "offline", "error": f"NAS {NAS_IP} ping 실패"}
# 2. 마운트 상태 확인
if not NAS_MOUNT_POINT.exists():
return {"status": "not_mounted", "error": "마운트 포인트 없음"}
# 3. 실제 마운트 확인
mount_result = subprocess.run(
["mount"],
capture_output=True,
text=True
)
if str(NAS_MOUNT_POINT) not in mount_result.stdout:
return {"status": "not_mounted", "error": "NAS가 마운트되지 않음"}
# 4. Document-upload 폴더 확인
if not DOCUMENT_UPLOAD_BASE.exists():
return {"status": "folder_missing", "error": "Document-upload 폴더 없음"}
return {"status": "connected", "mount_point": str(NAS_MOUNT_POINT)}
except subprocess.TimeoutExpired:
return {"status": "timeout", "error": "연결 타임아웃"}
except Exception as e:
return {"status": "error", "error": str(e)}
@app.on_event("startup")
async def startup_event():
"""서버 시작시 전체 시스템 상태 확인 및 백그라운드 서비스 시작"""
logger.info(f"🚀 Mac Mini AI 번역 서버 시작 (v2.0)")
logger.info(f"📍 Mac Mini IP: {MAC_MINI_IP}")
logger.info(f"📍 NAS IP: {NAS_IP}")
logger.info("-" * 50)
# 백그라운드 AI 서비스 시작
await ai_service.start_service()
# NAS 연결 상태 확인
nas_status = check_nas_connection()
if nas_status["status"] == "connected":
logger.info(f"✅ NAS 연결 정상: {nas_status['mount_point']}")
# 필요한 폴더 구조 자동 생성
try:
current_month = datetime.now().strftime("%Y-%m")
folders_to_create = [
NAS_ORIGINALS_PATH / current_month / "pdfs",
NAS_ORIGINALS_PATH / current_month / "docs",
NAS_ORIGINALS_PATH / current_month / "txts",
NAS_TRANSLATED_PATH / current_month / "english-to-korean",
NAS_TRANSLATED_PATH / current_month / "japanese-to-korean",
NAS_TRANSLATED_PATH / current_month / "korean-only",
NAS_STATIC_HOSTING_PATH / "docs",
NAS_STATIC_HOSTING_PATH / "assets",
NAS_METADATA_PATH / "processing-logs"
]
for folder in folders_to_create:
folder.mkdir(parents=True, exist_ok=True)
logger.info(f"✅ 폴더 구조 확인/생성 완료")
except Exception as e:
logger.warning(f"⚠️ 폴더 생성 실패: {e}")
else:
logger.error(f"❌ NAS 연결 실패: {nas_status['error']}")
logger.info("해결 방법:")
logger.info("1. NAS 전원 및 네트워크 상태 확인")
logger.info("2. Finder에서 DS1525+ 수동 연결:")
logger.info(f" - 이동 → 서버에 연결 → smb://{NAS_IP}")
logger.info("3. 연결 후 서버 재시작")
# ==========================================
# 기존 엔드포인트들
# ==========================================
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
"""메인 페이지"""
nas_status = check_nas_connection()
return templates.TemplateResponse("index.html", {
"request": request,
"nas_status": nas_status
})
@app.get("/system-status")
async def system_status():
"""전체 시스템 상태 확인"""
# NAS 상태
nas_status = check_nas_connection()
# Mac Mini 상태
mac_status = {
"ip": MAC_MINI_IP,
"hostname": subprocess.getoutput("hostname"),
"uptime": subprocess.getoutput("uptime"),
"disk_usage": {}
}
# 디스크 사용량 확인
try:
disk_result = subprocess.run(
["df", "-h", str(Path.home())],
capture_output=True,
text=True
)
if disk_result.returncode == 0:
lines = disk_result.stdout.strip().split('\n')
if len(lines) > 1:
parts = lines[1].split()
mac_status["disk_usage"] = {
"total": parts[1],
"used": parts[2],
"available": parts[3],
"percentage": parts[4]
}
except:
pass
# AI 모델 상태 (백그라운드 서비스에서 가져오기)
ai_status = ai_service.get_dashboard_data()
# 작업 통계
job_stats = {
"total_jobs": len(processing_jobs),
"completed": len([j for j in processing_jobs.values() if j["status"] == "completed"]),
"processing": len([j for j in processing_jobs.values() if j["status"] == "processing"]),
"failed": len([j for j in processing_jobs.values() if j["status"] == "error"])
}
return {
"timestamp": datetime.now().isoformat(),
"nas": nas_status,
"mac_mini": mac_status,
"ai_models": ai_status,
"jobs": job_stats
}
@app.post("/upload")
async def upload_file(background_tasks: BackgroundTasks, file: UploadFile = File(...)):
"""파일 업로드 (백그라운드 AI 서비스 연동)"""
# NAS 연결 상태 먼저 확인
nas_status = check_nas_connection()
if nas_status["status"] != "connected":
raise HTTPException(
status_code=503,
detail=f"NAS 연결 실패: {nas_status['error']}. Finder에서 DS1525+를 연결해주세요."
)
# 파일 검증
if not file.filename:
raise HTTPException(status_code=400, detail="파일이 선택되지 않았습니다.")
allowed_extensions = {'.pdf', '.txt', '.docx', '.doc'}
file_ext = Path(file.filename).suffix.lower()
if file_ext not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"지원되지 않는 파일 형식입니다. 지원 형식: {', '.join(allowed_extensions)}"
)
# 파일 크기 확인 (100MB 제한)
content = await file.read()
if len(content) > 100 * 1024 * 1024:
raise HTTPException(status_code=413, detail="파일 크기는 100MB를 초과할 수 없습니다.")
# 고유 작업 ID 생성
job_id = str(uuid.uuid4())
# NAS에 체계적으로 저장
current_month = datetime.now().strftime("%Y-%m")
file_type_folder = {
'.pdf': 'pdfs',
'.doc': 'docs',
'.docx': 'docs',
'.txt': 'txts'
}.get(file_ext, 'others')
nas_original_dir = NAS_ORIGINALS_PATH / current_month / file_type_folder
nas_original_dir.mkdir(parents=True, exist_ok=True)
# 안전한 파일명 생성
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
safe_filename = f"{timestamp}_{job_id[:8]}_{file.filename}".replace(" ", "_")
nas_file_path = nas_original_dir / safe_filename
# 파일 저장
try:
async with aiofiles.open(nas_file_path, 'wb') as f:
await f.write(content)
logger.info(f"📁 파일 저장 완료: {nas_file_path}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"파일 저장 실패: {str(e)}")
# 작업 상태 초기화
job = {
"id": job_id,
"filename": file.filename,
"file_type": file_ext,
"status": "uploaded",
"progress": 0,
"message": f"파일 업로드 완료, NAS에 저장됨 ({nas_file_path.name})",
"nas_original_path": str(nas_file_path),
"created_at": time.time()
}
processing_jobs[job_id] = job
# 백그라운드 AI 서비스에 작업 추가
await ai_service.add_job({
'job_id': job_id,
'file_path': str(nas_file_path),
'original_filename': file.filename
})
return {
"job_id": job_id,
"message": "파일 업로드 완료, 백그라운드 AI 처리를 시작합니다.",
"nas_path": str(nas_file_path)
}
@app.get("/status/{job_id}")
async def get_job_status(job_id: str):
"""작업 상태 조회"""
if job_id not in processing_jobs:
raise HTTPException(status_code=404, detail="작업을 찾을 수 없습니다.")
return processing_jobs[job_id]
# ==========================================
# 새로운 대시보드 엔드포인트들
# ==========================================
@app.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request):
"""AI 모델 대시보드 페이지"""
return templates.TemplateResponse("dashboard.html", {
"request": request
})
@app.get("/api/dashboard")
async def get_dashboard_data():
"""대시보드용 실시간 데이터 API"""
return ai_service.get_dashboard_data()
@app.post("/api/restart-models")
async def restart_models():
"""AI 모델 재시작 API"""
try:
await ai_service.restart_models()
return {"message": "AI 모델 재시작이 완료되었습니다."}
except Exception as e:
raise HTTPException(status_code=500, detail=f"모델 재시작 실패: {str(e)}")
@app.post("/api/clear-cache")
async def clear_cache():
"""시스템 캐시 정리 API"""
try:
# 여기서 실제 캐시 정리 로직 구현
# 예: 임시 파일 삭제, 메모리 정리 등
import gc
gc.collect() # 가비지 컬렉션 실행
return {"message": "시스템 캐시가 정리되었습니다."}
except Exception as e:
raise HTTPException(status_code=500, detail=f"캐시 정리 실패: {str(e)}")
@app.get("/api/models/status")
async def get_models_status():
"""AI 모델 상태만 조회하는 API"""
dashboard_data = ai_service.get_dashboard_data()
return dashboard_data.get('models_status', {})
@app.get("/api/metrics/history")
async def get_metrics_history():
"""성능 지표 히스토리 조회 API"""
dashboard_data = ai_service.get_dashboard_data()
return dashboard_data.get('recent_metrics', [])
# ==========================================
# 유틸리티 함수들
# ==========================================
async def save_processing_metadata(job_id: str, original_path: Path, html_path: Path, metadata: Dict):
"""처리 메타데이터 NAS에 저장"""
log_data = {
"job_id": job_id,
"timestamp": datetime.now().isoformat(),
"original_file": str(original_path),
"html_file": str(html_path),
"metadata": metadata,
"nas_ip": NAS_IP,
"mac_mini_ip": MAC_MINI_IP,
"ai_service_version": "2.0"
}
# 월별 로그 파일에 추가
current_month = datetime.now().strftime("%Y-%m")
log_file = NAS_METADATA_PATH / "processing-logs" / f"{current_month}.jsonl"
try:
async with aiofiles.open(log_file, 'a', encoding='utf-8') as f:
await f.write(json.dumps(log_data, ensure_ascii=False) + '\n')
except Exception as e:
logger.error(f"메타데이터 저장 실패: {e}")
if __name__ == "__main__":
import uvicorn
logger.info(f"🚀 Mac Mini AI 번역 서버 with 대시보드")
logger.info(f"📡 서버 주소: http://{MAC_MINI_IP}:8080")
logger.info(f"📊 대시보드: http://{MAC_MINI_IP}:8080/dashboard")
logger.info(f"📁 NAS 주소: {NAS_IP}")
uvicorn.run(app, host="0.0.0.0", port=8080)

585
templates/dashboard.html Normal file
View File

@@ -0,0 +1,585 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 모델 대시보드 - NLLB 번역 시스템</title>
<style>
:root {
--primary-color: #2563eb;
--success-color: #059669;
--warning-color: #d97706;
--error-color: #dc2626;
--background-color: #f8fafc;
--text-color: #1e293b;
--border-color: #e2e8f0;
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', sans-serif;
line-height: 1.6;
color: var(--text-color);
background: var(--background-color);
}
.dashboard-container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.header {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: var(--shadow);
border: 1px solid var(--border-color);
}
.header h1 {
font-size: 2rem;
font-weight: 700;
color: var(--primary-color);
margin-bottom: 8px;
}
.header .subtitle {
color: #64748b;
font-size: 1.1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 24px;
margin-bottom: 24px;
}
.card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: var(--shadow);
border: 1px solid var(--border-color);
}
.card-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color);
}
.card-header h3 {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color);
}
.card-icon {
font-size: 1.5rem;
}
.model-status {
display: flex;
flex-direction: column;
gap: 16px;
}
.model-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.model-info h4 {
font-weight: 600;
margin-bottom: 4px;
}
.model-details {
font-size: 0.875rem;
color: #64748b;
}
.status-badge {
padding: 6px 12px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status-ready {
background: #dcfce7;
color: var(--success-color);
}
.status-loading {
background: #fef3c7;
color: var(--warning-color);
}
.status-error {
background: #fee2e2;
color: var(--error-color);
}
.status-unloaded {
background: #f1f5f9;
color: #64748b;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.metric-item {
text-align: center;
padding: 16px;
background: #f8fafc;
border-radius: 8px;
}
.metric-value {
font-size: 2rem;
font-weight: 700;
color: var(--primary-color);
margin-bottom: 4px;
}
.metric-label {
font-size: 0.875rem;
color: #64748b;
}
.progress-bar {
width: 100%;
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
margin: 8px 0;
}
.progress-fill {
height: 100%;
background: var(--primary-color);
transition: width 0.3s ease;
}
.control-buttons {
display: flex;
gap: 12px;
margin-top: 20px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-primary:hover {
background: #1d4ed8;
}
.btn-secondary {
background: #64748b;
color: white;
}
.btn-secondary:hover {
background: #475569;
}
.job-list {
max-height: 300px;
overflow-y: auto;
}
.job-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--border-color);
}
.job-item:last-child {
border-bottom: none;
}
.job-id {
font-family: monospace;
font-size: 0.75rem;
color: #64748b;
}
.auto-refresh {
position: fixed;
top: 20px;
right: 20px;
background: white;
padding: 12px;
border-radius: 8px;
box-shadow: var(--shadow);
border: 1px solid var(--border-color);
}
.refresh-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--success-color);
margin-right: 8px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.chart-container {
height: 200px;
position: relative;
margin-top: 16px;
}
@media (max-width: 768px) {
.dashboard-container {
padding: 12px;
}
.grid {
grid-template-columns: 1fr;
}
.metrics-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="auto-refresh">
<div style="display: flex; align-items: center;">
<div class="refresh-indicator"></div>
<span style="font-size: 0.875rem;">실시간 업데이트</span>
</div>
</div>
<div class="dashboard-container">
<div class="header">
<h1>🤖 AI 모델 대시보드</h1>
<p class="subtitle">NLLB 번역 시스템 - 실시간 모니터링</p>
</div>
<div class="grid">
<!-- 모델 상태 카드 -->
<div class="card">
<div class="card-header">
<span class="card-icon">🧠</span>
<h3>AI 모델 상태</h3>
</div>
<div class="model-status" id="modelStatus">
<!-- 동적으로 채워짐 -->
</div>
<div class="control-buttons">
<button class="btn btn-primary" onclick="restartModels()">
🔄 모델 재시작
</button>
<button class="btn btn-secondary" onclick="clearCache()">
🗑️ 캐시 정리
</button>
</div>
</div>
<!-- 시스템 성능 카드 -->
<div class="card">
<div class="card-header">
<span class="card-icon">📊</span>
<h3>시스템 성능</h3>
</div>
<div class="metrics-grid" id="systemMetrics">
<!-- 동적으로 채워짐 -->
</div>
</div>
<!-- 작업 통계 카드 -->
<div class="card">
<div class="card-header">
<span class="card-icon">📈</span>
<h3>작업 통계</h3>
</div>
<div class="metrics-grid" id="jobStats">
<!-- 동적으로 채워짐 -->
</div>
<div class="chart-container">
<canvas id="performanceChart" width="400" height="200"></canvas>
</div>
</div>
<!-- 활성 작업 카드 -->
<div class="card">
<div class="card-header">
<span class="card-icon"></span>
<h3>활성 작업</h3>
</div>
<div class="job-list" id="activeJobs">
<!-- 동적으로 채워짐 -->
</div>
</div>
</div>
</div>
<script>
// 대시보드 데이터 관리
let dashboardData = {};
let performanceChart = null;
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', function() {
initializeChart();
loadDashboardData();
// 5초마다 자동 새로고침
setInterval(loadDashboardData, 5000);
});
// 대시보드 데이터 로드
async function loadDashboardData() {
try {
const response = await fetch('/api/dashboard');
dashboardData = await response.json();
updateModelStatus();
updateSystemMetrics();
updateJobStats();
updateActiveJobs();
updatePerformanceChart();
} catch (error) {
console.error('대시보드 데이터 로드 실패:', error);
}
}
// 모델 상태 업데이트
function updateModelStatus() {
const container = document.getElementById('modelStatus');
container.innerHTML = '';
if (dashboardData.models_status) {
Object.values(dashboardData.models_status).forEach(model => {
const modelItem = document.createElement('div');
modelItem.className = 'model-item';
const memoryUsage = model.memory_usage_mb ? `${model.memory_usage_mb.toFixed(0)}MB` : 'N/A';
const totalProcessed = model.total_processed || 0;
const lastUsed = model.last_used ? new Date(model.last_used).toLocaleTimeString() : 'N/A';
modelItem.innerHTML = `
<div class="model-info">
<h4>${model.name}</h4>
<div class="model-details">
메모리: ${memoryUsage} | 처리 완료: ${totalProcessed}개 | 마지막 사용: ${lastUsed}
</div>
</div>
<span class="status-badge status-${model.status}">${getStatusText(model.status)}</span>
`;
container.appendChild(modelItem);
});
}
}
// 시스템 메트릭 업데이트
function updateSystemMetrics() {
const container = document.getElementById('systemMetrics');
container.innerHTML = '';
if (dashboardData.current_metrics) {
const metrics = dashboardData.current_metrics;
const metricItems = [
{ value: `${metrics.total_memory_usage_mb.toFixed(0)}MB`, label: '메모리 사용량' },
{ value: `${metrics.cpu_usage_percent.toFixed(1)}%`, label: 'CPU 사용률' },
{ value: formatUptime(metrics.uptime_seconds), label: '서비스 가동시간' },
{ value: `${metrics.average_processing_time.toFixed(1)}`, label: '평균 처리시간' }
];
metricItems.forEach(item => {
const metricDiv = document.createElement('div');
metricDiv.className = 'metric-item';
metricDiv.innerHTML = `
<div class="metric-value">${item.value}</div>
<div class="metric-label">${item.label}</div>
`;
container.appendChild(metricDiv);
});
}
}
// 작업 통계 업데이트
function updateJobStats() {
const container = document.getElementById('jobStats');
container.innerHTML = '';
if (dashboardData.current_metrics) {
const metrics = dashboardData.current_metrics;
const jobItems = [
{ value: metrics.active_jobs, label: '진행 중인 작업' },
{ value: metrics.queued_jobs, label: '대기 중인 작업' },
{ value: metrics.completed_jobs_today, label: '오늘 완료된 작업' },
{ value: dashboardData.completed_today || 0, label: '총 완료 작업' }
];
jobItems.forEach(item => {
const jobDiv = document.createElement('div');
jobDiv.className = 'metric-item';
jobDiv.innerHTML = `
<div class="metric-value">${item.value}</div>
<div class="metric-label">${item.label}</div>
`;
container.appendChild(jobDiv);
});
}
}
// 활성 작업 업데이트
function updateActiveJobs() {
const container = document.getElementById('activeJobs');
if (dashboardData.active_jobs === 0) {
container.innerHTML = '<div style="text-align: center; color: #64748b; padding: 20px;">현재 진행 중인 작업이 없습니다.</div>';
} else {
container.innerHTML = `<div style="text-align: center; color: #2563eb; padding: 20px;">현재 ${dashboardData.active_jobs}개의 작업이 진행 중입니다.</div>`;
}
}
// 성능 차트 초기화
function initializeChart() {
const canvas = document.getElementById('performanceChart');
const ctx = canvas.getContext('2d');
// 간단한 차트 구현 (실제로는 Chart.js 등 사용 권장)
performanceChart = {
canvas: canvas,
ctx: ctx,
data: []
};
}
// 성능 차트 업데이트
function updatePerformanceChart() {
if (!performanceChart || !dashboardData.recent_metrics) return;
const ctx = performanceChart.ctx;
const canvas = performanceChart.canvas;
// 캔버스 지우기
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 간단한 메모리 사용량 그래프 그리기
const metrics = dashboardData.recent_metrics.slice(-20); // 최근 20개
if (metrics.length === 0) return;
const maxMemory = Math.max(...metrics.map(m => m.metrics.total_memory_usage_mb));
const width = canvas.width;
const height = canvas.height;
ctx.strokeStyle = '#2563eb';
ctx.lineWidth = 2;
ctx.beginPath();
metrics.forEach((metric, index) => {
const x = (index / (metrics.length - 1)) * width;
const y = height - (metric.metrics.total_memory_usage_mb / maxMemory) * height;
if (index === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
// 레이블 추가
ctx.fillStyle = '#64748b';
ctx.font = '12px Arial';
ctx.fillText('메모리 사용량 추이', 10, 20);
ctx.fillText(`현재: ${metrics[metrics.length - 1]?.metrics.total_memory_usage_mb.toFixed(0)}MB`, 10, height - 10);
}
// 헬퍼 함수들
function getStatusText(status) {
const statusMap = {
'ready': '준비완료',
'loading': '로딩중',
'error': '오류',
'unloaded': '로드안됨'
};
return statusMap[status] || status;
}
function formatUptime(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}시간 ${minutes}`;
}
// 제어 함수들
async function restartModels() {
if (confirm('AI 모델을 재시작하시겠습니까? 진행 중인 작업이 중단될 수 있습니다.')) {
try {
await fetch('/api/restart-models', { method: 'POST' });
alert('모델 재시작을 시작했습니다. 잠시 후 상태가 업데이트됩니다.');
} catch (error) {
alert('모델 재시작 실패: ' + error.message);
}
}
}
async function clearCache() {
if (confirm('시스템 캐시를 정리하시겠습니까?')) {
try {
await fetch('/api/clear-cache', { method: 'POST' });
alert('캐시 정리가 완료되었습니다.');
} catch (error) {
alert('캐시 정리 실패: ' + error.message);
}
}
}
</script>
</body>
</html>