#!/usr/bin/env python3 """ 팜큐 약국 관리 시스템 - Flask 애플리케이션 Headscale + Headplane 고도화 관리자 페이지 """ from flask import Flask, render_template, jsonify, request, redirect, url_for from flask_socketio import SocketIO, emit, disconnect import os import json from datetime import datetime import uuid from config import config import asyncio import threading from utils.database_new import ( init_databases, get_farmq_session, get_dashboard_stats, get_all_pharmacies_with_stats, get_all_machines_with_details, get_machine_detail, get_pharmacy_detail, get_active_alerts, sync_machines_from_headscale, sync_users_from_headscale ) from models.farmq_models import PharmacyInfo from sqlalchemy import or_ import subprocess from utils.proxmox_client import ProxmoxClient from utils.vnc_proxy import init_vnc_proxy, get_vnc_proxy from utils.vnc_websocket_proxy import vnc_proxy import websockets import requests from urllib3.exceptions import InsecureRequestWarning requests.packages.urllib3.disable_warnings(InsecureRequestWarning) # PBS API 설정 PBS_HOST = "100.64.0.8" PBS_PORT = "8007" PBS_USERNAME = "root@pam" PBS_PASSWORD = "trajet6640" PBS_BASE_URL = f"https://{PBS_HOST}:{PBS_PORT}/api2/json" def pbs_get_auth_ticket(): """PBS 인증 티켓 획득""" try: response = requests.post( f"{PBS_BASE_URL}/access/ticket", data={ 'username': PBS_USERNAME, 'password': PBS_PASSWORD }, verify=False, timeout=10 ) if response.status_code == 200: data = response.json()['data'] return { 'ticket': data['ticket'], 'csrf_token': data['CSRFPreventionToken'] } return None except Exception as e: print(f"PBS 인증 실패: {e}") return None def pbs_api_call(endpoint, auth_info=None): """PBS API 호출""" if not auth_info: auth_info = pbs_get_auth_ticket() if not auth_info: return None try: headers = { 'Cookie': f"PBSAuthCookie={auth_info['ticket']}", 'CSRFPreventionToken': auth_info['csrf_token'] } response = requests.get( f"{PBS_BASE_URL}/{endpoint}", headers=headers, verify=False, timeout=10 ) if response.status_code == 200: return response.json()['data'] return None except Exception as e: print(f"PBS API 호출 실패 ({endpoint}): {e}") return None def create_app(config_name=None): """Flask 애플리케이션 팩토리""" app = Flask(__name__) # 설정 로드 config_name = config_name or os.environ.get('FLASK_ENV', 'default') app.config.from_object(config[config_name]) # SocketIO 초기화 socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading') # 데이터베이스 초기화 init_databases( headscale_db_uri='sqlite:////srv/headscale-setup/data/db.sqlite', farmq_db_uri='sqlite:///farmq.db' ) # 데이터 동기화 실행 sync_users_from_headscale() sync_machines_from_headscale() # VNC 세션 관리 (메모리 기반) vnc_sessions = {} # 다중 Proxmox 서버 설정 PROXMOX_HOSTS = { 'pve7.0bin.in': { 'host': 'pve7.0bin.in', 'username': 'root@pam', 'password': 'trajet6640', 'port': 443, 'name': 'PVE 7.0 (Main)', 'default': True }, 'healthport_pve': { 'host': '100.64.0.6', 'username': 'root@pam', 'password': 'healthport', 'port': 8006, 'name': 'Healthport PVE', 'default': False } } # 기본 호스트 가져오기 def get_default_host(): for host_key, host_config in PROXMOX_HOSTS.items(): if host_config.get('default', False): return host_key, host_config # 기본값이 없으면 첫 번째 호스트 반환 return next(iter(PROXMOX_HOSTS.items())) # 호스트 설정 가져오기 def get_host_config(host_key=None): if not host_key: return get_default_host() return host_key, PROXMOX_HOSTS.get(host_key, get_default_host()[1]) # VNC 프록시 초기화 (기본 호스트로) default_host_key, default_host_config = get_default_host() init_vnc_proxy(default_host_config['host'], default_host_config['username'], default_host_config['password']) # 메인 대시보드 @app.route('/wstest') def websocket_test(): """WebSocket 연결 테스트 페이지""" return render_template('websocket_test.html') @app.route('/') def dashboard(): """메인 대시보드""" try: # 새로운 통합 통계 함수 사용 stats = get_dashboard_stats() stats['alerts'] = get_active_alerts()[:5] # 최신 5개만 stats['performance'] = {'status': 'good', 'summary': '모든 시스템이 정상 작동 중입니다.'} # 약국별 상태 (상위 10개) pharmacies = get_all_pharmacies_with_stats()[:10] return render_template('dashboard/index.html', stats=stats, pharmacies=pharmacies) except Exception as e: print(f"❌ Dashboard error: {e}") return render_template('error.html', error=str(e)), 500 # 약국 관리 @app.route('/pharmacy') def pharmacy_list(): """약국 목록""" try: pharmacies = get_all_pharmacies_with_stats() return render_template('pharmacy/list.html', pharmacies=pharmacies) except Exception as e: return render_template('error.html', error=str(e)), 500 @app.route('/pharmacy/') def pharmacy_detail(pharmacy_id): """약국 상세 정보""" try: detail_data = get_pharmacy_detail(pharmacy_id) if not detail_data: return render_template('error.html', error='약국을 찾을 수 없습니다.'), 404 return render_template('pharmacy/detail.html', pharmacy=detail_data['pharmacy'], machines=detail_data['machines']) except Exception as e: print(f"❌ Pharmacy detail error: {e}") return render_template('error.html', error=str(e)), 500 # 머신 관리 @app.route('/machines') def machine_list(): """머신 목록""" try: machines = get_all_machines_with_details() return render_template('machines/list.html', machines=machines) except Exception as e: print(f"❌ Machine list error: {e}") return render_template('error.html', error=str(e)), 500 @app.route('/machines/') def machine_detail(machine_id): """머신 상세 정보""" try: print(f"🔍 Getting details for machine ID: {machine_id}") details = get_machine_detail(machine_id) if not details: print(f"❌ No details found for machine ID: {machine_id}") return render_template('error.html', error='머신을 찾을 수 없습니다.'), 404 hostname = details.get('hostname', 'Unknown') print(f"✅ Rendering detail page for machine: {hostname}") # 템플릿에 필요한 추가 변수들 is_online = details.get('online', False) last_seen = details.get('last_seen') # 시간 형식화 if last_seen: from datetime import datetime, timezone if isinstance(last_seen, datetime): now = datetime.now(timezone.utc) if last_seen.tzinfo else datetime.now() delta = now - last_seen if delta.days > 0: last_seen_humanized = f"{delta.days}일 전" elif delta.seconds > 3600: last_seen_humanized = f"{delta.seconds // 3600}시간 전" elif delta.seconds > 60: last_seen_humanized = f"{delta.seconds // 60}분 전" else: last_seen_humanized = "방금 전" else: last_seen_humanized = "알 수 없음" else: last_seen_humanized = "알 수 없음" return render_template('machines/detail.html', machine=details, is_online=is_online, last_seen_humanized=last_seen_humanized) except Exception as e: print(f"❌ Error in machine_detail route: {e}") import traceback traceback.print_exc() return render_template('error.html', error=f'머신 상세 정보 로드 중 오류: {str(e)}'), 500 # API 엔드포인트 @app.route('/api/dashboard/stats') def api_dashboard_stats(): """대시보드 통계 API""" try: stats = get_dashboard_stats() stats['performance'] = {'status': 'good', 'summary': '모든 시스템이 정상 작동 중입니다.'} return jsonify(stats) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/alerts') def api_alerts(): """실시간 알림 API""" try: alerts = get_active_alerts() return jsonify(alerts) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/machines//monitoring') def api_machine_monitoring(machine_id): """머신 모니터링 데이터 API""" try: details = get_machine_detail(machine_id) if not details: return jsonify({'error': '머신을 찾을 수 없습니다.'}), 404 # 최근 모니터링 데이터 반환 metrics_history = details.get('metrics_history', []) return jsonify(metrics_history[:20]) # 최근 20개 데이터 except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/sync/machines') def api_sync_machines(): """Headscale에서 머신 정보 동기화 API""" try: result = sync_machines_from_headscale() return jsonify(result) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/sync/users') def api_sync_users(): """Headscale에서 사용자 정보 동기화 API""" try: result = sync_users_from_headscale() return jsonify(result) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/machines//rename', methods=['POST']) def api_rename_machine(machine_id): """머신 이름 변경 API""" try: data = request.get_json() if not data or 'new_name' not in data: return jsonify({'error': '새 이름이 필요합니다.'}), 400 new_name = data['new_name'].strip() if not new_name: return jsonify({'error': '유효한 이름을 입력해주세요.'}), 400 # 이름 유효성 검사 (DNS 호환) import re if not re.match(r'^[a-z0-9]([a-z0-9-]*[a-z0-9])?$', new_name): return jsonify({ 'error': '이름은 소문자, 숫자, 하이픈(-)만 사용 가능하며, 하이픈으로 시작하거나 끝날 수 없습니다.' }), 400 print(f"🔄 머신 이름 변경 요청: ID={machine_id}, 새 이름={new_name}") # Headscale CLI 명령 실행 result = subprocess.run([ 'docker', 'exec', 'headscale', 'headscale', 'nodes', 'rename', new_name, '--identifier', str(machine_id), '--output', 'json' ], capture_output=True, text=True, timeout=30) if result.returncode != 0: error_msg = result.stderr.strip() or result.stdout.strip() print(f"❌ 머신 이름 변경 실패: {error_msg}") return jsonify({'error': f'이름 변경 실패: {error_msg}'}), 500 print(f"✅ 머신 이름 변경 성공: {new_name}") # 성공 응답 return jsonify({ 'success': True, 'message': f'머신 이름이 "{new_name}"로 변경되었습니다.', 'new_name': new_name, 'new_magic_dns': f'{new_name}.headscale.local' }) except subprocess.TimeoutExpired: return jsonify({'error': '요청 시간이 초과되었습니다. 다시 시도해주세요.'}), 500 except Exception as e: print(f"❌ 머신 이름 변경 오류: {e}") return jsonify({'error': f'서버 오류: {str(e)}'}), 500 @app.route('/api/pharmacy/', methods=['GET']) def api_get_pharmacy(pharmacy_id): """개별 약국 정보 조회 API""" try: from utils.database_new import get_farmq_session from models.farmq_models import PharmacyInfo session = get_farmq_session() try: pharmacy = session.query(PharmacyInfo).filter( PharmacyInfo.id == pharmacy_id ).first() if not pharmacy: return jsonify({'error': '약국을 찾을 수 없습니다.'}), 404 return jsonify({ 'pharmacy': { 'id': pharmacy.id, 'pharmacy_name': pharmacy.pharmacy_name or '', 'business_number': pharmacy.business_number or '', 'manager_name': pharmacy.manager_name or '', 'phone': pharmacy.phone or '', 'address': pharmacy.address or '', 'proxmox_host': pharmacy.proxmox_host or '', 'user_id': pharmacy.headscale_user_name or '', # user_id 대신 headscale_user_name 사용 'headscale_user_name': pharmacy.headscale_user_name or '' } }) finally: session.close() except Exception as e: return jsonify({'error': str(e)}), 500 # 약국 관리 API @app.route('/api/pharmacy', methods=['POST']) def api_create_pharmacy(): """새 약국 생성""" try: data = request.get_json() # 필수 필드 확인 pharmacy_name = data.get('pharmacy_name', '').strip() if not pharmacy_name: return jsonify({ 'success': False, 'error': '약국명은 필수입니다.' }), 400 # FARMQ 데이터베이스에 약국 생성 farmq_session = get_farmq_session() try: new_pharmacy = PharmacyInfo( pharmacy_name=pharmacy_name, business_number=data.get('business_number', '').strip(), manager_name=data.get('manager_name', '').strip(), phone=data.get('phone', '').strip(), address=data.get('address', '').strip(), proxmox_host=data.get('proxmox_host', '').strip(), headscale_user_name=data.get('headscale_user_name', '').strip(), status='active' ) farmq_session.add(new_pharmacy) farmq_session.commit() return jsonify({ 'success': True, 'message': f'약국 "{pharmacy_name}"가 성공적으로 생성되었습니다.', 'pharmacy': new_pharmacy.to_dict() }) finally: farmq_session.close() except Exception as e: print(f"❌ 약국 생성 오류: {e}") return jsonify({ 'success': False, 'error': f'서버 오류: {str(e)}' }), 500 @app.route('/api/pharmacy/', methods=['PUT']) def api_update_pharmacy(pharmacy_id): """약국 정보 수정""" try: data = request.get_json() farmq_session = get_farmq_session() try: pharmacy = farmq_session.query(PharmacyInfo).filter( PharmacyInfo.id == pharmacy_id ).first() if not pharmacy: return jsonify({ 'success': False, 'error': '약국을 찾을 수 없습니다.' }), 404 # 필드 업데이트 if 'pharmacy_name' in data: pharmacy.pharmacy_name = data['pharmacy_name'].strip() if 'business_number' in data: pharmacy.business_number = data['business_number'].strip() if 'manager_name' in data: pharmacy.manager_name = data['manager_name'].strip() if 'phone' in data: pharmacy.phone = data['phone'].strip() if 'address' in data: pharmacy.address = data['address'].strip() if 'proxmox_host' in data: pharmacy.proxmox_host = data['proxmox_host'].strip() if 'headscale_user_name' in data: pharmacy.headscale_user_name = data['headscale_user_name'].strip() pharmacy.updated_at = datetime.now() farmq_session.commit() return jsonify({ 'success': True, 'message': f'약국 "{pharmacy.pharmacy_name}" 정보가 수정되었습니다.', 'pharmacy': pharmacy.to_dict() }) finally: farmq_session.close() except Exception as e: print(f"❌ 약국 수정 오류: {e}") return jsonify({ 'success': False, 'error': f'서버 오류: {str(e)}' }), 500 @app.route('/api/pharmacy//delete', methods=['DELETE']) def api_delete_pharmacy(pharmacy_id): """약국 삭제""" try: farmq_session = get_farmq_session() try: pharmacy = farmq_session.query(PharmacyInfo).filter( PharmacyInfo.id == pharmacy_id ).first() if not pharmacy: return jsonify({ 'success': False, 'error': '약국을 찾을 수 없습니다.' }), 404 pharmacy_name = pharmacy.pharmacy_name farmq_session.delete(pharmacy) farmq_session.commit() return jsonify({ 'success': True, 'message': f'약국 "{pharmacy_name}"가 삭제되었습니다.' }) finally: farmq_session.close() except Exception as e: print(f"❌ 약국 삭제 오류: {e}") return jsonify({ 'success': False, 'error': f'서버 오류: {str(e)}' }), 500 @app.route('/api/pharmacies/available', methods=['GET']) def api_get_available_pharmacies(): """매핑되지 않은 약국 목록 가져오기""" try: farmq_session = get_farmq_session() try: # headscale_user_name이 NULL이거나 빈 문자열인 약국들만 가져오기 pharmacies = farmq_session.query(PharmacyInfo).filter( or_( PharmacyInfo.headscale_user_name.is_(None), PharmacyInfo.headscale_user_name == '' ) ).all() pharmacy_list = [] for pharmacy in pharmacies: pharmacy_list.append({ 'id': pharmacy.id, 'pharmacy_name': pharmacy.pharmacy_name, 'manager_name': pharmacy.manager_name or '미등록', 'business_number': pharmacy.business_number, 'address': pharmacy.address }) return jsonify({ 'success': True, 'pharmacies': pharmacy_list, 'count': len(pharmacy_list) }) finally: farmq_session.close() except Exception as e: print(f"❌ 매핑 가능한 약국 목록 조회 오류: {e}") return jsonify({ 'success': False, 'error': f'서버 오류: {str(e)}' }), 500 @app.route('/api/users//link-pharmacy', methods=['POST']) def api_link_user_pharmacy(user_name): """사용자와 약국 연결""" try: data = request.get_json() pharmacy_id = data.get('pharmacy_id') if not pharmacy_id: return jsonify({ 'success': False, 'error': '약국 ID가 필요합니다.' }), 400 farmq_session = get_farmq_session() try: # 약국 존재 확인 pharmacy = farmq_session.query(PharmacyInfo).filter( PharmacyInfo.id == pharmacy_id ).first() if not pharmacy: return jsonify({ 'success': False, 'error': '약국을 찾을 수 없습니다.' }), 404 # 사용자 존재 확인 (Headscale CLI 사용) result = subprocess.run( ['docker', 'exec', 'headscale', 'headscale', 'users', 'list', '-o', 'json'], capture_output=True, text=True, check=True ) users_data = json.loads(result.stdout) user = next((u for u in users_data if u['name'] == user_name), None) if not user: return jsonify({ 'success': False, 'error': f'사용자 "{user_name}"를 찾을 수 없습니다.' }), 404 # 약국에 사용자 연결 pharmacy.headscale_user_name = user_name pharmacy.headscale_user_id = user['id'] pharmacy.updated_at = datetime.now() farmq_session.commit() return jsonify({ 'success': True, 'message': f'사용자 "{user_name}"가 약국 "{pharmacy.pharmacy_name}"에 연결되었습니다.' }) finally: farmq_session.close() except subprocess.CalledProcessError as e: error_msg = e.stderr if e.stderr else e.stdout return jsonify({ 'success': False, 'error': f'사용자 확인 실패: {error_msg}' }), 400 except Exception as e: print(f"❌ 사용자-약국 연결 오류: {e}") return jsonify({ 'success': False, 'error': f'서버 오류: {str(e)}' }), 500 # VNC 관리 라우트들 @app.route('/vms') def vm_list(): """VM 목록 페이지""" try: # 요청된 호스트 가져오기 requested_host = request.args.get('host') current_host_key, current_host_config = get_host_config(requested_host) # Proxmox 클라이언트 생성 및 로그인 client = ProxmoxClient( current_host_config['host'], current_host_config['username'], current_host_config['password'], port=current_host_config['port'] ) if not client.login(): return render_template('error.html', error=f'Proxmox 서버({current_host_config["name"]})에 연결할 수 없습니다.'), 500 # VM 목록 가져오기 vms = client.get_vm_list() # 통계 계산 total_vms = len(vms) running_vms = len([vm for vm in vms if vm.get('status') == 'running']) stopped_vms = total_vms - running_vms vnc_ready_vms = running_vms # 실행 중인 VM은 모두 VNC 가능 return render_template('vm_list.html', vms=vms, available_hosts=PROXMOX_HOSTS, current_host_key=current_host_key, current_host_name=current_host_config['name'], current_host_info=current_host_config, total_vms=total_vms, running_vms=running_vms, stopped_vms=stopped_vms, vnc_ready_vms=vnc_ready_vms) except Exception as e: print(f"❌ VM 목록 오류: {e}") return render_template('error.html', error=str(e)), 500 @app.route('/api/vm/vnc', methods=['POST']) def api_vm_vnc(): """VNC 연결 세션 생성 API""" try: data = request.get_json() node = data.get('node') vmid = int(data.get('vmid')) vm_name = data.get('vm_name', f'VM-{vmid}') host_key = data.get('host') # 호스트 설정 가져오기 current_host_key, current_host_config = get_host_config(host_key) # Proxmox 클라이언트 생성 및 로그인 client = ProxmoxClient( current_host_config['host'], current_host_config['username'], current_host_config['password'], port=current_host_config['port'] ) if not client.login(): return jsonify({'error': f'Proxmox 서버({current_host_config["name"]}) 로그인 실패'}), 500 # VM 상태 확인 vm_status = client.get_vm_status(node, vmid) print(f"🔍 VM {vmid} 상태: {vm_status}") # VNC 티켓 생성 vnc_data = client.get_vnc_ticket(node, vmid) if not vnc_data: return jsonify({'error': 'VNC 티켓 생성 실패'}), 500 # 세션 ID 생성 session_id = str(uuid.uuid4()) # VNC 세션 저장 session_data = { 'node': node, 'vmid': vmid, 'vm_name': vm_name, 'websocket_url': vnc_data['websocket_url'], 'password': vnc_data.get('password', ''), # VNC 패스워드 추가 'vm_status': vm_status.get('status', 'unknown'), # VM 상태 추가 'host_key': current_host_key, # 호스트 키 저장 'created_at': datetime.now() } vnc_sessions[session_id] = session_data # 세션을 파일로 저장 (WebSocket 프록시 서버용) import json session_file = f"/tmp/vnc_session_{session_id}.json" with open(session_file, 'w', encoding='utf-8') as f: json.dump(session_data, f, ensure_ascii=False, default=str) # WebSocket 프록시 세션 생성 vnc_proxy.create_session(session_id, vnc_data['websocket_url'], session_data) return jsonify({ 'session_id': session_id, 'vm_name': vm_name, 'success': True }) except Exception as e: print(f"❌ VNC API 오류: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/vnc/refresh/', methods=['POST']) def api_vnc_refresh_ticket(session_id): """VNC 티켓 새로고침 API""" try: # 세션 확인 if session_id not in vnc_sessions: return jsonify({'error': '유효하지 않은 VNC 세션입니다.'}), 404 session_data = vnc_sessions[session_id] node = session_data['node'] vmid = session_data['vmid'] host_key = session_data.get('host_key') print(f"🔄 VNC 티켓 새로고침 요청: {node}/{vmid} (host: {host_key})") # 호스트 설정 가져오기 current_host_key, current_host_config = get_host_config(host_key) # Proxmox 클라이언트 생성 client = ProxmoxClient( current_host_config['host'], current_host_config['username'], current_host_config['password'], port=current_host_config['port'] ) if not client.login(): return jsonify({'error': 'Proxmox 로그인 실패'}), 500 # 새 VNC 티켓 생성 vnc_data = client.get_vnc_ticket(node, vmid) if not vnc_data: return jsonify({'error': '새 VNC 티켓 생성 실패'}), 500 # 세션 업데이트 vnc_sessions[session_id].update({ 'websocket_url': vnc_data['websocket_url'], 'password': vnc_data.get('password', ''), 'updated_at': datetime.now() }) # 세션 파일도 업데이트 (WebSocket 프록시 서버용) import json session_file = f"/tmp/vnc_session_{session_id}.json" with open(session_file, 'w', encoding='utf-8') as f: json.dump(vnc_sessions[session_id], f, ensure_ascii=False, default=str) print(f"✅ VNC 티켓 새로고침 완료: {session_id}") return jsonify({ 'success': True, 'websocket_url': vnc_data['websocket_url'], 'password': vnc_data.get('password', ''), 'message': '새 VNC 티켓이 생성되었습니다.' }) except Exception as e: print(f"❌ VNC 티켓 새로고침 실패: {e}") return jsonify({'error': str(e)}), 500 @app.route('/vnc/') def vnc_console(session_id): """VNC 콘솔 페이지""" try: # 세션 확인 if session_id not in vnc_sessions: return render_template('error.html', error='유효하지 않은 VNC 세션입니다.'), 404 session_data = vnc_sessions[session_id] # WebSocket 프록시를 통한 VNC 연결 return render_template('vnc_proxy_websocket.html', vm_name=session_data['vm_name'], vmid=session_data['vmid'], node=session_data['node'], websocket_url=session_data['websocket_url'], password=session_data.get('password', ''), session_id=session_id, vm_status=session_data.get('vm_status', 'unknown')) except Exception as e: print(f"❌ VNC 콘솔 오류: {e}") return render_template('error.html', error=str(e)), 500 @app.route('/vnc//proxy') def vnc_console_proxy(session_id): """VNC 콘솔 페이지 (Socket.IO 프록시 버전)""" try: # 세션 확인 if session_id not in vnc_sessions: return render_template('error.html', error='유효하지 않은 VNC 세션입니다.'), 404 session_data = vnc_sessions[session_id] # Socket.IO 기반 VNC 프록시 사용 return render_template('vnc_proxy.html', vm_name=session_data['vm_name'], vmid=session_data['vmid'], node=session_data['node'], session_id=session_id) except Exception as e: print(f"❌ VNC 프록시 콘솔 오류: {e}") return render_template('error.html', error=str(e)), 500 # WebSocket 라우터는 Flask-SocketIO를 통해 처리됩니다. # 클라이언트는 ws://domain/socket.io/를 통해 연결하고, # 'vnc_proxy_connect' 이벤트를 발생시켜야 합니다. @app.route('/vnc//ssl-help') def vnc_ssl_help(session_id): """VNC SSL 인증서 도움말 페이지""" try: # 세션 확인 if session_id not in vnc_sessions: return render_template('error.html', error='유효하지 않은 VNC 세션입니다.'), 404 session_data = vnc_sessions[session_id] host_key = session_data.get('host_key', 'pve7.0bin.in') # 호스트 설정 가져오기 current_host_key, current_host_config = get_host_config(host_key) return render_template('vnc_ssl_help.html', vm_name=session_data['vm_name'], vmid=session_data['vmid'], node=session_data['node'], session_id=session_id, websocket_url=session_data['websocket_url'], proxmox_host=current_host_config['host'], proxmox_port=current_host_config['port'], host_key=current_host_key) except Exception as e: print(f"❌ VNC SSL 도움말 오류: {e}") return render_template('error.html', error=str(e)), 500 @app.route('/api/vm/start', methods=['POST']) def api_vm_start(): """VM 시작 API""" try: data = request.get_json() node = data.get('node') vmid = int(data.get('vmid')) host_key = data.get('host') # 호스트 설정 가져오기 current_host_key, current_host_config = get_host_config(host_key) # Proxmox 클라이언트 생성 및 로그인 client = ProxmoxClient( current_host_config['host'], current_host_config['username'], current_host_config['password'], port=current_host_config['port'] ) if not client.login(): return jsonify({'error': f'Proxmox 서버({current_host_config["name"]}) 로그인 실패'}), 500 # VM 시작 success = client.start_vm(node, vmid) if success: return jsonify({'message': f'VM {vmid} 시작 명령을 전송했습니다.', 'success': True}) else: return jsonify({'error': 'VM 시작 실패'}), 500 except Exception as e: print(f"❌ VM 시작 오류: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/vm/stop', methods=['POST']) def api_vm_stop(): """VM 정지 API""" try: data = request.get_json() node = data.get('node') vmid = int(data.get('vmid')) host_key = data.get('host') # 호스트 설정 가져오기 current_host_key, current_host_config = get_host_config(host_key) # Proxmox 클라이언트 생성 및 로그인 client = ProxmoxClient( current_host_config['host'], current_host_config['username'], current_host_config['password'], port=current_host_config['port'] ) if not client.login(): return jsonify({'error': f'Proxmox 서버({current_host_config["name"]}) 로그인 실패'}), 500 # VM 정지 success = client.stop_vm(node, vmid) if success: return jsonify({'message': f'VM {vmid} 정지 명령을 전송했습니다.', 'success': True}) else: return jsonify({'error': 'VM 정지 실패'}), 500 except Exception as e: print(f"❌ VM 정지 오류: {e}") return jsonify({'error': str(e)}), 500 # 노드 삭제 API @app.route('/api/nodes//delete', methods=['DELETE']) def api_delete_node(node_id): """노드 삭제 API""" try: # Headscale CLI를 통해 노드 삭제 result = subprocess.run( ['docker', 'exec', 'headscale', 'headscale', 'nodes', 'delete', '-i', str(node_id), '--force'], capture_output=True, text=True, check=True ) # 삭제 성공 return jsonify({ 'success': True, 'message': f'노드 {node_id}가 성공적으로 삭제되었습니다.', 'output': result.stdout }) except subprocess.CalledProcessError as e: # Headscale CLI 오류 error_msg = e.stderr if e.stderr else e.stdout return jsonify({ 'success': False, 'error': f'노드 삭제 실패: {error_msg}' }), 400 except Exception as e: # 기타 오류 print(f"❌ 노드 삭제 오류: {e}") return jsonify({ 'success': False, 'error': f'서버 오류: {str(e)}' }), 500 # 사용자 관리 라우트 @app.route('/users') def users_list(): """사용자 관리 페이지""" return render_template('users/list.html') # 사용자 관리 API @app.route('/api/users', methods=['GET']) def api_get_users(): """Headscale 사용자 목록 조회""" try: # Headscale CLI를 통해 사용자 목록 조회 result = subprocess.run( ['docker', 'exec', 'headscale', 'headscale', 'users', 'list', '-o', 'json'], capture_output=True, text=True, check=True ) users_data = json.loads(result.stdout) # FARMQ 약국 정보와 매칭 (명시적으로 매핑된 것만) farmq_session = get_farmq_session() try: pharmacies = farmq_session.query(PharmacyInfo).all() # 명시적으로 headscale_user_name이 설정되고 해당 사용자가 실제로 존재하는 경우만 매핑 pharmacy_map = {} for p in pharmacies: if p.headscale_user_name and p.headscale_user_name.strip(): pharmacy_map[p.headscale_user_name] = p # 사용자별 노드 수 조회 for user in users_data: user_name = user.get('name', '') # 약국 정보 매칭 - 명시적으로 연결된 것만 pharmacy = pharmacy_map.get(user_name) user['pharmacy'] = { 'id': pharmacy.id, 'name': pharmacy.pharmacy_name, 'manager': pharmacy.manager_name } if pharmacy else None # 해당 사용자의 노드 수 조회 node_result = subprocess.run( ['docker', 'exec', 'headscale', 'headscale', 'nodes', 'list', '-o', 'json'], capture_output=True, text=True, check=True ) nodes_data = json.loads(node_result.stdout) user['node_count'] = len([n for n in nodes_data if n.get('user', {}).get('name') == user_name]) return jsonify({'success': True, 'users': users_data}) finally: farmq_session.close() except subprocess.CalledProcessError as e: error_msg = e.stderr if e.stderr else e.stdout return jsonify({ 'success': False, 'error': f'사용자 목록 조회 실패: {error_msg}' }), 400 except Exception as e: print(f"❌ 사용자 목록 조회 오류: {e}") return jsonify({ 'success': False, 'error': f'서버 오류: {str(e)}' }), 500 @app.route('/api/users', methods=['POST']) def api_create_user(): """새 Headscale 사용자 생성""" try: data = request.get_json() user_name = data.get('name', '').strip() display_name = data.get('display_name', '').strip() email = data.get('email', '').strip() if not user_name: return jsonify({ 'success': False, 'error': '사용자 이름은 필수입니다.' }), 400 # Headscale CLI를 통해 사용자 생성 cmd = ['docker', 'exec', 'headscale', 'headscale', 'users', 'create', user_name] if display_name: cmd.extend(['-d', display_name]) if email: cmd.extend(['-e', email]) result = subprocess.run( cmd, capture_output=True, text=True, check=True ) return jsonify({ 'success': True, 'message': f'사용자 "{user_name}"가 성공적으로 생성되었습니다.', 'output': result.stdout }) except subprocess.CalledProcessError as e: error_msg = e.stderr if e.stderr else e.stdout return jsonify({ 'success': False, 'error': f'사용자 생성 실패: {error_msg}' }), 400 except Exception as e: print(f"❌ 사용자 생성 오류: {e}") return jsonify({ 'success': False, 'error': f'서버 오류: {str(e)}' }), 500 @app.route('/api/users//delete', methods=['DELETE']) def api_delete_user(user_name): """Headscale 사용자 삭제""" try: # Headscale CLI를 통해 사용자 삭제 result = subprocess.run( ['docker', 'exec', 'headscale', 'headscale', 'users', 'destroy', '--name', user_name, '--force'], capture_output=True, text=True, check=True ) return jsonify({ 'success': True, 'message': f'사용자 "{user_name}"가 성공적으로 삭제되었습니다.', 'output': result.stdout }) except subprocess.CalledProcessError as e: error_msg = e.stderr if e.stderr else e.stdout return jsonify({ 'success': False, 'error': f'사용자 삭제 실패: {error_msg}' }), 400 except Exception as e: print(f"❌ 사용자 삭제 오류: {e}") return jsonify({ 'success': False, 'error': f'서버 오류: {str(e)}' }), 500 # =================== 구독 서비스 관리 API =================== @app.route('/api/subscriptions/stats', methods=['GET']) def api_subscription_stats(): """대시보드용 구독 현황 통계""" try: import sqlite3 db_path = '/srv/headscale-setup/farmq-admin/farmq.db' conn = sqlite3.connect(db_path) cursor = conn.cursor() # 서비스별 구독 현황 cursor.execute(''' SELECT sp.product_code, sp.product_name, COUNT(ps.id) as subscription_count, SUM(ps.monthly_fee) as total_monthly_revenue FROM service_products sp LEFT JOIN pharmacy_subscriptions ps ON sp.id = ps.product_id AND ps.subscription_status = 'ACTIVE' GROUP BY sp.id, sp.product_code, sp.product_name ORDER BY sp.product_code ''') services = [] total_revenue = 0 total_subscriptions = 0 for row in cursor.fetchall(): product_code, product_name, count, revenue = row revenue = revenue or 0 services.append({ 'code': product_code, 'name': product_name, 'count': count, 'revenue': revenue }) total_revenue += revenue total_subscriptions += count # 전체 약국 수 cursor.execute("SELECT COUNT(*) FROM pharmacies") total_pharmacies = cursor.fetchone()[0] # 구독 중인 약국 수 cursor.execute(''' SELECT COUNT(DISTINCT pharmacy_id) FROM pharmacy_subscriptions WHERE subscription_status = 'ACTIVE' ''') subscribed_pharmacies = cursor.fetchone()[0] conn.close() return jsonify({ 'success': True, 'data': { 'services': services, 'total_revenue': total_revenue, 'total_subscriptions': total_subscriptions, 'total_pharmacies': total_pharmacies, 'subscribed_pharmacies': subscribed_pharmacies, 'subscription_rate': round(subscribed_pharmacies / total_pharmacies * 100, 1) if total_pharmacies > 0 else 0 } }) except Exception as e: print(f"❌ 구독 통계 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/pharmacies/subscriptions', methods=['GET']) def api_pharmacy_subscriptions(): """약국별 구독 현황 조회""" try: import sqlite3 db_path = '/srv/headscale-setup/farmq-admin/farmq.db' conn = sqlite3.connect(db_path) cursor = conn.cursor() # 약국별 구독 현황 조회 cursor.execute(''' SELECT p.id, p.pharmacy_name, p.manager_name, p.address, GROUP_CONCAT(sp.product_code) as subscribed_services, SUM(ps.monthly_fee) as total_monthly_fee FROM pharmacies p LEFT JOIN pharmacy_subscriptions ps ON p.id = ps.pharmacy_id AND ps.subscription_status = 'ACTIVE' LEFT JOIN service_products sp ON ps.product_id = sp.id GROUP BY p.id, p.pharmacy_name, p.manager_name, p.address ORDER BY total_monthly_fee DESC NULLS LAST, p.pharmacy_name ''') pharmacies = [] for row in cursor.fetchall(): pharmacy_id, name, manager, address, services, fee = row # 구독 서비스 리스트 변환 service_list = [] if services: for service_code in services.split(','): service_list.append(service_code) pharmacies.append({ 'id': pharmacy_id, 'name': name, 'manager': manager, 'address': address, 'subscribed_services': service_list, 'monthly_fee': fee or 0 }) conn.close() return jsonify({ 'success': True, 'data': pharmacies }) except Exception as e: print(f"❌ 약국 구독 현황 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/pharmacy//subscriptions', methods=['GET']) def api_pharmacy_subscription_detail(pharmacy_id): """특정 약국의 구독 상세 현황""" try: import sqlite3 db_path = '/srv/headscale-setup/farmq-admin/farmq.db' conn = sqlite3.connect(db_path) cursor = conn.cursor() # 약국 기본 정보 cursor.execute("SELECT pharmacy_name FROM pharmacies WHERE id = ?", (pharmacy_id,)) pharmacy_result = cursor.fetchone() if not pharmacy_result: return jsonify({'success': False, 'error': '약국을 찾을 수 없습니다.'}), 404 # 구독 중인 서비스 cursor.execute(''' SELECT ps.id, sp.product_code, sp.product_name, ps.monthly_fee, ps.start_date, ps.next_billing_date, ps.subscription_status, ps.notes FROM pharmacy_subscriptions ps JOIN service_products sp ON ps.product_id = sp.id WHERE ps.pharmacy_id = ? AND ps.subscription_status = 'ACTIVE' ORDER BY sp.product_name ''', (pharmacy_id,)) active_subscriptions = [] for row in cursor.fetchall(): sub_id, code, name, fee, start, next_bill, status, notes = row active_subscriptions.append({ 'id': sub_id, 'code': code, 'name': name, 'monthly_fee': fee, 'start_date': start, 'next_billing_date': next_bill, 'status': status, 'notes': notes }) # 구독 가능한 서비스 (미구독 서비스) cursor.execute(''' SELECT sp.id, sp.product_code, sp.product_name, sp.monthly_price, sp.description FROM service_products sp WHERE sp.is_active = 1 AND sp.id NOT IN ( SELECT ps.product_id FROM pharmacy_subscriptions ps WHERE ps.pharmacy_id = ? AND ps.subscription_status = 'ACTIVE' ) ORDER BY sp.product_name ''', (pharmacy_id,)) available_services = [] for row in cursor.fetchall(): prod_id, code, name, price, desc = row available_services.append({ 'id': prod_id, 'code': code, 'name': name, 'monthly_price': price, 'description': desc }) conn.close() return jsonify({ 'success': True, 'data': { 'pharmacy_name': pharmacy_result[0], 'active_subscriptions': active_subscriptions, 'available_services': available_services } }) except Exception as e: print(f"❌ 약국 구독 상세 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/subscriptions', methods=['POST']) def api_create_subscription(): """새 구독 생성""" try: import sqlite3 from datetime import date, timedelta data = request.get_json() pharmacy_id = data.get('pharmacy_id') product_id = data.get('product_id') monthly_fee = data.get('monthly_fee') notes = data.get('notes', '') if not pharmacy_id or not product_id: return jsonify({'success': False, 'error': '필수 파라미터가 누락되었습니다.'}), 400 db_path = '/srv/headscale-setup/farmq-admin/farmq.db' conn = sqlite3.connect(db_path) cursor = conn.cursor() # 중복 구독 확인 cursor.execute(''' SELECT id FROM pharmacy_subscriptions WHERE pharmacy_id = ? AND product_id = ? AND subscription_status = 'ACTIVE' ''', (pharmacy_id, product_id)) if cursor.fetchone(): conn.close() return jsonify({'success': False, 'error': '이미 구독 중인 서비스입니다.'}), 400 # 상품 정보 조회 cursor.execute("SELECT monthly_price FROM service_products WHERE id = ?", (product_id,)) product_result = cursor.fetchone() if not product_result: conn.close() return jsonify({'success': False, 'error': '존재하지 않는 서비스입니다.'}), 404 # 월 구독료 설정 (사용자 지정 또는 기본 가격) if monthly_fee is None: monthly_fee = product_result[0] # 구독 생성 start_date = date.today() next_billing_date = start_date + timedelta(days=30) cursor.execute(''' INSERT INTO pharmacy_subscriptions (pharmacy_id, product_id, subscription_status, start_date, next_billing_date, monthly_fee, notes) VALUES (?, ?, 'ACTIVE', ?, ?, ?, ?) ''', (pharmacy_id, product_id, start_date.isoformat(), next_billing_date.isoformat(), monthly_fee, notes)) conn.commit() subscription_id = cursor.lastrowid conn.close() return jsonify({ 'success': True, 'message': '구독이 성공적으로 생성되었습니다.', 'subscription_id': subscription_id }) except Exception as e: print(f"❌ 구독 생성 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/subscriptions/', methods=['DELETE']) def api_cancel_subscription(subscription_id): """구독 해지""" try: import sqlite3 db_path = '/srv/headscale-setup/farmq-admin/farmq.db' conn = sqlite3.connect(db_path) cursor = conn.cursor() # 구독 존재 확인 cursor.execute("SELECT id FROM pharmacy_subscriptions WHERE id = ?", (subscription_id,)) if not cursor.fetchone(): conn.close() return jsonify({'success': False, 'error': '존재하지 않는 구독입니다.'}), 404 # 구독 상태를 CANCELLED로 변경 cursor.execute(''' UPDATE pharmacy_subscriptions SET subscription_status = 'CANCELLED', updated_at = CURRENT_TIMESTAMP WHERE id = ? ''', (subscription_id,)) conn.commit() conn.close() return jsonify({ 'success': True, 'message': '구독이 성공적으로 해지되었습니다.' }) except Exception as e: print(f"❌ 구독 해지 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 # =================== 구독 서비스 관리 페이지 =================== @app.route('/subscriptions') def subscriptions_page(): """구독 서비스 관리 페이지""" return render_template('subscriptions/list.html') # =================== 매출 대시보드 =================== @app.route('/revenue') def revenue_dashboard(): """매출 대시보드 페이지""" return render_template('revenue/dashboard.html') @app.route('/api/analytics/revenue/monthly', methods=['GET']) def api_monthly_revenue(): """월별 매출 통계""" try: import sqlite3 from datetime import datetime, timedelta import calendar db_path = '/srv/headscale-setup/farmq-admin/farmq.db' conn = sqlite3.connect(db_path) cursor = conn.cursor() # 최근 12개월 매출 데이터 생성 current_date = datetime.now() monthly_data = [] for i in range(12): # i개월 전 날짜 계산 target_date = current_date - timedelta(days=30*i) year = target_date.year month = target_date.month month_name = calendar.month_name[month] # 해당 월의 구독 수 및 매출 계산 cursor.execute(''' SELECT COUNT(ps.id) as subscription_count, SUM(ps.monthly_fee) as total_revenue FROM pharmacy_subscriptions ps WHERE ps.subscription_status = 'ACTIVE' AND ps.start_date <= ? ''', (f'{year}-{month:02d}-28',)) result = cursor.fetchone() subscription_count = result[0] or 0 total_revenue = result[1] or 0 monthly_data.insert(0, { 'month': f'{year}-{month:02d}', 'month_name': f'{month_name[:3]} {year}', 'subscription_count': subscription_count, 'revenue': total_revenue }) conn.close() return jsonify({ 'success': True, 'data': monthly_data }) except Exception as e: print(f"❌ 월별 매출 통계 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/analytics/revenue/by-service', methods=['GET']) def api_revenue_by_service(): """서비스별 매출 통계""" try: import sqlite3 db_path = '/srv/headscale-setup/farmq-admin/farmq.db' conn = sqlite3.connect(db_path) cursor = conn.cursor() # 서비스별 매출 통계 cursor.execute(''' SELECT sp.product_code, sp.product_name, COUNT(ps.id) as subscription_count, SUM(ps.monthly_fee) as total_revenue FROM service_products sp LEFT JOIN pharmacy_subscriptions ps ON sp.id = ps.product_id AND ps.subscription_status = 'ACTIVE' GROUP BY sp.id, sp.product_code, sp.product_name ORDER BY total_revenue DESC NULLS LAST ''') services = [] total_revenue = 0 for row in cursor.fetchall(): product_code, product_name, count, revenue = row revenue = revenue or 0 total_revenue += revenue services.append({ 'code': product_code, 'name': product_name, 'subscription_count': count, 'revenue': revenue }) # 비율 계산 for service in services: service['percentage'] = round((service['revenue'] / total_revenue * 100), 1) if total_revenue > 0 else 0 conn.close() return jsonify({ 'success': True, 'data': { 'services': services, 'total_revenue': total_revenue } }) except Exception as e: print(f"❌ 서비스별 매출 통계 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/analytics/pharmacy-ranking', methods=['GET']) def api_pharmacy_ranking(): """약국별 구독료 순위""" try: import sqlite3 db_path = '/srv/headscale-setup/farmq-admin/farmq.db' conn = sqlite3.connect(db_path) cursor = conn.cursor() # 약국별 구독료 순위 (상위 10개) cursor.execute(''' SELECT p.pharmacy_name, p.manager_name, COUNT(ps.id) as service_count, SUM(ps.monthly_fee) as total_monthly_fee FROM pharmacies p JOIN pharmacy_subscriptions ps ON p.id = ps.pharmacy_id WHERE ps.subscription_status = 'ACTIVE' GROUP BY p.id, p.pharmacy_name, p.manager_name ORDER BY total_monthly_fee DESC LIMIT 10 ''') rankings = [] for i, row in enumerate(cursor.fetchall(), 1): pharmacy_name, manager_name, service_count, total_fee = row rankings.append({ 'rank': i, 'pharmacy_name': pharmacy_name, 'manager_name': manager_name, 'service_count': service_count, 'monthly_fee': total_fee }) conn.close() return jsonify({ 'success': True, 'data': rankings }) except Exception as e: print(f"❌ 약국별 순위 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/analytics/subscription-trends', methods=['GET']) def api_subscription_trends(): """구독 트렌드 통계 (신규/해지)""" try: import sqlite3 from datetime import datetime, timedelta db_path = '/srv/headscale-setup/farmq-admin/farmq.db' conn = sqlite3.connect(db_path) cursor = conn.cursor() # 최근 6개월 구독 트렌드 trends = [] current_date = datetime.now() for i in range(6): target_date = current_date - timedelta(days=30*i) year = target_date.year month = target_date.month # 해당 월 신규 구독 수 cursor.execute(''' SELECT COUNT(*) FROM pharmacy_subscriptions WHERE strftime('%Y-%m', start_date) = ? ''', (f'{year}-{month:02d}',)) new_subscriptions = cursor.fetchone()[0] # 해당 월 해지 구독 수 cursor.execute(''' SELECT COUNT(*) FROM pharmacy_subscriptions WHERE subscription_status = 'CANCELLED' AND strftime('%Y-%m', updated_at) = ? ''', (f'{year}-{month:02d}',)) cancelled_subscriptions = cursor.fetchone()[0] trends.insert(0, { 'month': f'{year}-{month:02d}', 'new_subscriptions': new_subscriptions, 'cancelled_subscriptions': cancelled_subscriptions, 'net_growth': new_subscriptions - cancelled_subscriptions }) conn.close() return jsonify({ 'success': True, 'data': trends }) except Exception as e: print(f"❌ 구독 트렌드 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 # =================== PBS 백업 서버 모니터링 =================== @app.route('/pbs') def pbs_monitoring(): """PBS 백업 서버 모니터링 페이지""" return render_template('pbs/monitoring.html') @app.route('/api/pbs/status') def api_pbs_status(): """PBS 서버 상태 및 기본 정보 조회""" try: auth_info = pbs_get_auth_ticket() if not auth_info: return jsonify({'success': False, 'error': 'PBS 서버 인증 실패'}), 500 # 서버 버전 정보 version_data = pbs_api_call('version', auth_info) if not version_data: return jsonify({'success': False, 'error': 'PBS 버전 정보 조회 실패'}), 500 return jsonify({ 'success': True, 'data': { 'status': 'online', 'version': version_data.get('version', 'unknown'), 'release': version_data.get('release', 'unknown'), 'server_time': datetime.now().isoformat() } }) except Exception as e: print(f"❌ PBS 상태 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/pbs/datastores') def api_pbs_datastores(): """PBS 데이터스토어 정보 조회""" try: auth_info = pbs_get_auth_ticket() if not auth_info: return jsonify({'success': False, 'error': 'PBS 서버 인증 실패'}), 500 # 데이터스토어 목록 datastores = pbs_api_call('config/datastore', auth_info) if not datastores: return jsonify({'success': False, 'error': '데이터스토어 정보 조회 실패'}), 500 # 각 데이터스토어의 사용량 정보 조회 datastore_info = [] for store in datastores: store_name = store.get('name') if not store_name: continue # 데이터스토어 사용량 조회 status_data = pbs_api_call(f'admin/datastore/{store_name}/status', auth_info) if status_data: total_bytes = status_data.get('total', 0) used_bytes = status_data.get('used', 0) avail_bytes = status_data.get('avail', 0) # 바이트를 GB로 변환 total_gb = round(total_bytes / (1024**3), 2) used_gb = round(used_bytes / (1024**3), 2) avail_gb = round(avail_bytes / (1024**3), 2) usage_percent = round((used_bytes / total_bytes * 100), 1) if total_bytes > 0 else 0 datastore_info.append({ 'name': store_name, 'comment': store.get('comment', ''), 'path': store.get('path', ''), 'total_gb': total_gb, 'used_gb': used_gb, 'avail_gb': avail_gb, 'usage_percent': usage_percent }) else: datastore_info.append({ 'name': store_name, 'comment': store.get('comment', ''), 'path': store.get('path', ''), 'total_gb': 0, 'used_gb': 0, 'avail_gb': 0, 'usage_percent': 0, 'error': 'Status unavailable' }) return jsonify({ 'success': True, 'data': datastore_info }) except Exception as e: print(f"❌ PBS 데이터스토어 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/pbs/tasks') def api_pbs_tasks(): """PBS 작업 상태 조회""" try: auth_info = pbs_get_auth_ticket() if not auth_info: return jsonify({'success': False, 'error': 'PBS 서버 인증 실패'}), 500 # 실행 중인 작업 running_tasks = pbs_api_call('nodes/localhost/tasks?running=true', auth_info) if running_tasks is None: running_tasks = [] # 최근 작업 (모든 상태) all_tasks = pbs_api_call('nodes/localhost/tasks?limit=10', auth_info) if all_tasks is None: all_tasks = [] return jsonify({ 'success': True, 'data': { 'running_tasks': running_tasks, 'recent_tasks': all_tasks, 'running_count': len(running_tasks) } }) except Exception as e: print(f"❌ PBS 작업 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/pbs/backups/') def api_pbs_backups(datastore_name): """PBS 백업 목록 조회 (상세)""" try: auth_info = pbs_get_auth_ticket() if not auth_info: return jsonify({'success': False, 'error': 'PBS 서버 인증 실패'}), 500 # 네임스페이스 목록 조회 (있는 경우) namespaces = [] try: ns_data = pbs_api_call(f'admin/datastore/{datastore_name}/namespace', auth_info) if ns_data: namespaces = [ns.get('ns', '') for ns in ns_data] except: namespaces = [''] # 기본 네임스페이스만 print(f"🔍 PBS 네임스페이스 목록: {namespaces}") # 모든 백업 그룹 조회 (네임스페이스별) all_backup_info = [] for ns in namespaces[:5]: # 최대 5개 네임스페이스 ns_param = f'ns={ns}' if ns else '' # 백업 그룹 조회 groups_url = f'admin/datastore/{datastore_name}/groups' if ns_param: groups_url += f'?{ns_param}' groups = pbs_api_call(groups_url, auth_info) if not groups: continue print(f"🔍 네임스페이스 '{ns}' 백업 그룹 수: {len(groups)}") # 각 그룹의 상세 정보 조회 for group in groups[:20]: # 네임스페이스당 최대 20개 그룹 backup_type = group.get('backup-type') backup_id = group.get('backup-id') group_ns = group.get('ns', '') if not backup_type or not backup_id: continue # 해당 그룹의 스냅샷 조회 snapshot_params = f'backup-type={backup_type}&backup-id={backup_id}' if group_ns: snapshot_params += f'&ns={group_ns}' snapshots = pbs_api_call( f'admin/datastore/{datastore_name}/snapshots?{snapshot_params}', auth_info ) # 수동으로 최신 10개만 선택 if snapshots and len(snapshots) > 10: snapshots = sorted(snapshots, key=lambda x: x.get('backup-time', 0), reverse=True)[:10] if snapshots: latest_snapshot = snapshots[0] if snapshots else None # 스냅샷 세부 정보 snapshot_details = [] for snap in snapshots[:5]: # 최신 5개만 상세 표시 snapshot_details.append({ 'backup_time': snap.get('backup-time'), 'size': snap.get('size', 0), 'protected': snap.get('protected', False), 'comment': snap.get('comment', ''), 'verification': snap.get('verification', {}) }) all_backup_info.append({ 'namespace': group_ns or 'root', 'type': backup_type, 'id': backup_id, 'last_backup': latest_snapshot.get('backup-time') if latest_snapshot else None, 'snapshot_count': len(snapshots), 'total_size': sum(s.get('size', 0) for s in snapshots), 'latest_size': latest_snapshot.get('size', 0) if latest_snapshot else 0, 'snapshots': snapshot_details, 'group_comment': group.get('comment', ''), 'last_verification': latest_snapshot.get('verification', {}) if latest_snapshot else {} }) # 크기별 정렬 (큰 것부터) all_backup_info.sort(key=lambda x: x['total_size'], reverse=True) return jsonify({ 'success': True, 'data': { 'namespaces': namespaces, 'backups': all_backup_info[:50], # 최대 50개 백업 그룹 표시 'total_groups': len(all_backup_info), 'datastore': datastore_name } }) except Exception as e: print(f"❌ PBS 백업 목록 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/pbs/backup-details///') def api_pbs_backup_details(datastore_name, backup_type, backup_id): """특정 백업 그룹의 상세 정보""" try: auth_info = pbs_get_auth_ticket() if not auth_info: return jsonify({'success': False, 'error': 'PBS 서버 인증 실패'}), 500 namespace = request.args.get('ns', '') ns_param = f'&ns={namespace}' if namespace else '' # 스냅샷 목록 조회 snapshots = pbs_api_call( f'admin/datastore/{datastore_name}/snapshots?backup-type={backup_type}&backup-id={backup_id}{ns_param}', auth_info ) if not snapshots: return jsonify({'success': False, 'error': '스냅샷 조회 실패'}), 500 # 스냅샷 상세 정보 snapshot_list = [] for snap in snapshots: snapshot_list.append({ 'backup_time': snap.get('backup-time'), 'size': snap.get('size', 0), 'protected': snap.get('protected', False), 'comment': snap.get('comment', ''), 'verification': snap.get('verification', {}), 'encrypted': snap.get('encrypted', False), 'fingerprint': snap.get('fingerprint', '') }) return jsonify({ 'success': True, 'data': { 'backup_type': backup_type, 'backup_id': backup_id, 'namespace': namespace, 'snapshots': snapshot_list, 'total_snapshots': len(snapshot_list), 'total_size': sum(s['size'] for s in snapshot_list) } }) except Exception as e: print(f"❌ PBS 백업 상세 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/pbs/restore-tasks') def api_pbs_restore_tasks(): """PBS 복구 작업 목록 조회""" try: auth_info = pbs_get_auth_ticket() if not auth_info: return jsonify({'success': False, 'error': 'PBS 서버 인증 실패'}), 500 # 모든 작업 조회 (복구 관련 작업 필터링) all_tasks = pbs_api_call('nodes/localhost/tasks', auth_info) if all_tasks is None: all_tasks = [] # 모든 작업을 카테고리별로 분류 restore_tasks = [] backup_tasks = [] other_tasks = [] for task in all_tasks: task_type = task.get('type', '') worker_type = task.get('worker_type', '') task_id = task.get('upid', '') task_info = { 'id': task_id, 'type': task_type or worker_type, # type이 비어있으면 worker_type 사용 'worker_type': worker_type, 'starttime': task.get('starttime'), 'endtime': task.get('endtime'), 'status': task.get('status'), 'exitstatus': task.get('exitstatus'), 'user': task.get('user'), 'node': task.get('node', 'localhost'), 'pid': task.get('pid'), 'pstart': task.get('pstart'), 'worker_id': task.get('worker_id') } # 실제 작업 타입 결정 (type이 비어있으면 worker_type 사용) actual_type = (task_type or worker_type).lower() # 복구 관련 작업 타입들 if any(restore_type in actual_type for restore_type in [ 'restore', 'download', 'extract', 'file-restore', 'vm-restore', 'reader' ]): restore_tasks.append(task_info) # 백업 관련 작업들 elif any(backup_type in actual_type for backup_type in [ 'backup', 'sync', 'verify', 'prune', 'gc', 'garbage-collection', 'upload' ]): backup_tasks.append(task_info) # 기타 작업들 (관리, 유지보수 등) else: other_tasks.append(task_info) # 시작시간 기준으로 최신 순 정렬 restore_tasks.sort(key=lambda x: x.get('starttime', 0), reverse=True) backup_tasks.sort(key=lambda x: x.get('starttime', 0), reverse=True) return jsonify({ 'success': True, 'data': { 'restore_tasks': restore_tasks[:20], # 최근 20개 'backup_tasks': backup_tasks[:20], # 최근 20개 'other_tasks': other_tasks[:10], # 기타 작업 10개 'total_restore_tasks': len(restore_tasks), 'total_backup_tasks': len(backup_tasks), 'total_other_tasks': len(other_tasks), 'total_all_tasks': len(all_tasks) } }) except Exception as e: print(f"❌ PBS 복구 작업 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/pbs/task-log/') def api_pbs_task_log(task_id): """PBS 작업 로그 조회""" try: auth_info = pbs_get_auth_ticket() if not auth_info: return jsonify({'success': False, 'error': 'PBS 서버 인증 실패'}), 500 # 작업 로그 조회 log_data = pbs_api_call(f'nodes/localhost/tasks/{task_id}/log', auth_info) if log_data is None: return jsonify({'success': False, 'error': '로그 조회 실패'}), 500 # 로그 라인들을 문자열로 변환 if isinstance(log_data, list): log_lines = [] for line in log_data: if isinstance(line, dict): # 로그 라인이 객체인 경우 timestamp = line.get('t', '') message = line.get('n', '') log_lines.append({ 'timestamp': timestamp, 'message': message, 'line': f"[{timestamp}] {message}" if timestamp else message }) else: # 로그 라인이 문자열인 경우 log_lines.append({ 'timestamp': '', 'message': str(line), 'line': str(line) }) else: log_lines = [{'timestamp': '', 'message': str(log_data), 'line': str(log_data)}] return jsonify({ 'success': True, 'data': { 'task_id': task_id, 'log_lines': log_lines, 'total_lines': len(log_lines) } }) except Exception as e: print(f"❌ PBS 작업 로그 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/pbs/task-status/') def api_pbs_task_status(task_id): """PBS 작업 상태 상세 조회""" try: auth_info = pbs_get_auth_ticket() if not auth_info: return jsonify({'success': False, 'error': 'PBS 서버 인증 실패'}), 500 # 작업 상태 조회 task_data = pbs_api_call(f'nodes/localhost/tasks/{task_id}/status', auth_info) if task_data is None: # 전체 작업 목록에서 해당 작업 찾기 all_tasks = pbs_api_call('nodes/localhost/tasks', auth_info) if all_tasks: task_data = next((t for t in all_tasks if t.get('upid') == task_id), None) if not task_data: return jsonify({'success': False, 'error': '작업을 찾을 수 없습니다'}), 404 return jsonify({ 'success': True, 'data': task_data }) except Exception as e: print(f"❌ PBS 작업 상태 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 # 에러 핸들러 @app.errorhandler(404) def not_found_error(error): return render_template('error.html', error='페이지를 찾을 수 없습니다.', error_code=404), 404 @app.errorhandler(500) def internal_error(error): return render_template('error.html', error='내부 서버 오류가 발생했습니다.', error_code=500), 500 # VNC WebSocket 프록시 핸들러 @socketio.on('vnc_proxy_connect') def handle_vnc_proxy_connect(data): """새로운 VNC WebSocket 프록시 연결 핸들러""" print(f"🔌 VNC 프록시 연결 요청: {data}") try: session_id = data.get('session_id') if not session_id: emit('vnc_error', {'error': '세션 ID가 필요합니다.'}) return # 세션 확인 if session_id not in vnc_sessions: emit('vnc_error', {'error': '유효하지 않은 세션입니다.'}) return session_data = vnc_sessions[session_id] proxy_session = vnc_proxy.get_session(session_id) if not proxy_session: emit('vnc_error', {'error': '프록시 세션을 찾을 수 없습니다.'}) return # WebSocket 프록시 연결 설정 완료 알림 emit('vnc_proxy_ready', { 'session_id': session_id, 'vm_name': session_data['vm_name'], 'password': session_data['password'], 'websocket_url': f"wss://pqadmin.0bin.in/vnc-ws/{session_id}" # 외부 URL로 변경 }) except Exception as e: print(f"❌ VNC 프록시 연결 오류: {e}") emit('vnc_error', {'error': str(e)}) @socketio.on('vnc_connect') def handle_vnc_connect_legacy(data): """레거시 VNC 연결 핸들러 (호환성 유지)""" print(f"🔌 레거시 VNC 연결 요청: {data}") try: vm_id = data.get('vm_id') node = data.get('node', 'pve7') if not vm_id: emit('vnc_error', {'error': 'VM ID가 필요합니다.'}) return # VNC 프록시 가져오기 legacy_vnc_proxy = get_vnc_proxy() if not legacy_vnc_proxy: emit('vnc_error', {'error': 'VNC 프록시가 초기화되지 않았습니다.'}) return # 비동기 VNC 프록시 시작을 별도 스레드에서 실행 def run_vnc_proxy(): # 간단한 동기 버전으로 시작 - 실제 WebSocket 중계는 나중에 구현 try: # 기본 호스트 설정 사용 current_host_key, current_host_config = get_host_config(None) client = ProxmoxClient( current_host_config['host'], current_host_config['username'], current_host_config['password'], port=current_host_config['port'] ) if not client.login(): socketio.emit('vnc_error', {'error': 'Proxmox 로그인 실패'}) return vnc_data = client.get_vnc_ticket(node, vm_id) if vnc_data: socketio.emit('vnc_ready', { 'vm_id': vm_id, 'node': node, 'websocket_url': vnc_data['websocket_url'], 'password': vnc_data['password'] }) else: socketio.emit('vnc_error', {'error': 'VNC 티켓 생성 실패'}) except Exception as e: print(f"❌ VNC 프록시 오류: {e}") socketio.emit('vnc_error', {'error': str(e)}) # 별도 스레드에서 실행 threading.Thread(target=run_vnc_proxy, daemon=True).start() except Exception as e: print(f"❌ VNC 연결 핸들러 오류: {e}") emit('vnc_error', {'error': str(e)}) @socketio.on('disconnect') def handle_disconnect(): """WebSocket 연결 종료 핸들러""" print('🔌 클라이언트 연결 종료') return app, socketio # 개발 서버 실행 if __name__ == '__main__': app, socketio = create_app() # 개발 환경에서만 실행 if app.config.get('DEBUG'): print("🚀 Starting FARMQ Admin System with WebSocket Support...") print(f"📊 Dashboard: http://localhost:5001") print(f"🏥 Pharmacy Management: http://localhost:5001/pharmacy") print(f"💻 Machine Management: http://localhost:5001/machines") print(f"🖥️ VM Management (VNC): http://localhost:5001/vms") print(f"🔌 WebSocket VNC Proxy: ws://localhost:5001/socket.io/") print("─" * 60) socketio.run( app, host='0.0.0.0', port=5001, debug=True, use_reloader=True, allow_unsafe_werkzeug=True )