diff --git a/farmq-admin/app.py b/farmq-admin/app.py index b39ca82..a217a91 100644 --- a/farmq-admin/app.py +++ b/farmq-admin/app.py @@ -7,6 +7,7 @@ Headscale + Headplane 고도화 관리자 페이지 from flask import Flask, render_template, jsonify, request, redirect, url_for import os from datetime import datetime +import uuid from config import config from utils.database_new import ( init_databases, get_farmq_session, @@ -14,6 +15,7 @@ from utils.database_new import ( get_machine_detail, get_pharmacy_detail, get_active_alerts, sync_machines_from_headscale, sync_users_from_headscale ) +from utils.proxmox_client import ProxmoxClient def create_app(config_name=None): """Flask 애플리케이션 팩토리""" @@ -33,6 +35,14 @@ def create_app(config_name=None): sync_users_from_headscale() sync_machines_from_headscale() + # VNC 세션 관리 (메모리 기반) + vnc_sessions = {} + + # Proxmox 서버 설정 + PROXMOX_HOST = "pve7.0bin.in" + PROXMOX_USERNAME = "root@pam" + PROXMOX_PASSWORD = "trajet6640" + # 메인 대시보드 @app.route('/') def dashboard(): @@ -162,6 +172,43 @@ def create_app(config_name=None): except Exception as e: return jsonify({'error': 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 + @app.route('/api/pharmacy//update', methods=['PUT']) def api_update_pharmacy(pharmacy_id): """약국 정보 업데이트 API""" @@ -206,6 +253,153 @@ def create_app(config_name=None): except Exception as e: return jsonify({'error': str(e)}), 500 + # VNC 관리 라우트들 + @app.route('/vms') + def vm_list(): + """VM 목록 페이지""" + try: + # Proxmox 클라이언트 생성 및 로그인 + client = ProxmoxClient(PROXMOX_HOST, PROXMOX_USERNAME, PROXMOX_PASSWORD) + if not client.login(): + return render_template('error.html', + error='Proxmox 서버에 연결할 수 없습니다.'), 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, + host=PROXMOX_HOST, + 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}') + + # Proxmox 클라이언트 생성 및 로그인 + client = ProxmoxClient(PROXMOX_HOST, PROXMOX_USERNAME, PROXMOX_PASSWORD) + 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 + + # 세션 ID 생성 + session_id = str(uuid.uuid4()) + + # VNC 세션 저장 + vnc_sessions[session_id] = { + 'node': node, + 'vmid': vmid, + 'vm_name': vm_name, + 'websocket_url': vnc_data['websocket_url'], + 'created_at': datetime.now() + } + + 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('/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] + + # Proxmox 기본 noVNC URL로 리다이렉트 + proxmox_vnc_url = f"https://{PROXMOX_HOST}:443/?console=kvm&vmid={session_data['vmid']}&node={session_data['node']}" + + # 리다이렉트 페이지 표시 + return render_template('vnc_redirect.html', + vm_name=session_data['vm_name'], + vmid=session_data['vmid'], + node=session_data['node'], + proxmox_url=proxmox_vnc_url, + host=PROXMOX_HOST) + + except Exception as e: + print(f"❌ VNC 콘솔 오류: {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')) + + # Proxmox 클라이언트 생성 및 로그인 + client = ProxmoxClient(PROXMOX_HOST, PROXMOX_USERNAME, PROXMOX_PASSWORD) + if not client.login(): + return jsonify({'error': 'Proxmox 서버 로그인 실패'}), 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')) + + # Proxmox 클라이언트 생성 및 로그인 + client = ProxmoxClient(PROXMOX_HOST, PROXMOX_USERNAME, PROXMOX_PASSWORD) + if not client.login(): + return jsonify({'error': 'Proxmox 서버 로그인 실패'}), 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 + # 에러 핸들러 @app.errorhandler(404) def not_found_error(error): @@ -231,6 +425,7 @@ if __name__ == '__main__': 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("─" * 60) app.run( diff --git a/farmq-admin/templates/machines/list.html b/farmq-admin/templates/machines/list.html index f9b3bf4..bb646a5 100644 --- a/farmq-admin/templates/machines/list.html +++ b/farmq-admin/templates/machines/list.html @@ -321,6 +321,9 @@ document.querySelectorAll('input[name="viewMode"]').forEach(radio => { } else { document.getElementById('cardView').classList.remove('d-none'); } + + // 뷰 변경 시 카운터 업데이트 + filterMachines(); }); }); @@ -343,7 +346,11 @@ function filterMachines() { let onlineCount = 0; let offlineCount = 0; - document.querySelectorAll('.machine-row, .machine-card').forEach(element => { + // 현재 활성화된 뷰만 선택 (List 또는 Card) + const isListView = document.getElementById('listView').checked; + const activeSelector = isListView ? '.machine-row' : '.machine-card'; + + document.querySelectorAll(activeSelector).forEach(element => { const machineText = element.textContent.toLowerCase(); const machineStatus = element.dataset.status; diff --git a/farmq-admin/templates/pharmacy/list.html b/farmq-admin/templates/pharmacy/list.html index b03f989..90a9f61 100644 --- a/farmq-admin/templates/pharmacy/list.html +++ b/farmq-admin/templates/pharmacy/list.html @@ -205,7 +205,14 @@ document.addEventListener('DOMContentLoaded', function() { function showAddModal() { document.getElementById('pharmacyModalTitle').innerHTML = ' 새 약국 등록'; + + // 폼 초기화 document.getElementById('pharmacyForm').reset(); + + // 새 등록 모드임을 표시 + document.getElementById('pharmacyForm').dataset.pharmacyId = ''; + document.getElementById('pharmacyForm').dataset.mode = 'add'; + pharmacyModal.show(); } @@ -213,8 +220,31 @@ function showEditModal(pharmacyId) { document.getElementById('pharmacyModalTitle').innerHTML = ' 약국 정보 수정'; - // TODO: 기존 데이터를 로드하여 폼에 채우기 - // fetch(`/api/pharmacy/${pharmacyId}`) + // 기존 데이터를 로드하여 폼에 채우기 + fetch(`/api/pharmacy/${pharmacyId}`) + .then(response => response.json()) + .then(data => { + if (data.pharmacy) { + const pharmacy = data.pharmacy; + + // 폼 필드에 기존 값들을 채우기 (value로 설정하여 수정 가능하게) + document.getElementById('pharmacy_name').value = pharmacy.pharmacy_name || ''; + document.getElementById('business_number').value = pharmacy.business_number || ''; + document.getElementById('manager_name').value = pharmacy.manager_name || ''; + document.getElementById('phone').value = pharmacy.phone || ''; + document.getElementById('address').value = pharmacy.address || ''; + document.getElementById('proxmox_host').value = pharmacy.proxmox_host || ''; + document.getElementById('user_id').value = pharmacy.user_id || ''; + + // 수정 모드임을 표시하기 위해 pharmacy ID를 form에 저장 + document.getElementById('pharmacyForm').dataset.pharmacyId = pharmacyId; + document.getElementById('pharmacyForm').dataset.mode = 'edit'; + } + }) + .catch(error => { + console.error('약국 정보 로드 실패:', error); + showToast('약국 정보를 불러오는데 실패했습니다.', 'error'); + }); pharmacyModal.show(); } @@ -222,15 +252,49 @@ function showEditModal(pharmacyId) { document.getElementById('pharmacyForm').addEventListener('submit', function(e) { e.preventDefault(); - const formData = new FormData(this); - const data = Object.fromEntries(formData); + const form = e.target; + const mode = form.dataset.mode; + const pharmacyId = form.dataset.pharmacyId; - // TODO: API를 통한 약국 정보 저장 - showToast('약국 정보가 저장되었습니다.', 'success'); - pharmacyModal.hide(); + // 폼 데이터 수집 + const data = { + pharmacy_name: document.getElementById('pharmacy_name').value, + business_number: document.getElementById('business_number').value, + manager_name: document.getElementById('manager_name').value, + phone: document.getElementById('phone').value, + address: document.getElementById('address').value, + proxmox_host: document.getElementById('proxmox_host').value, + user_id: document.getElementById('user_id').value + }; - // 페이지 새로고침 (임시) - setTimeout(() => location.reload(), 1000); + if (mode === 'edit' && pharmacyId) { + // 수정 모드: PUT 요청 + fetch(`/api/pharmacy/${pharmacyId}/update`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data) + }) + .then(response => response.json()) + .then(result => { + if (result.error) { + showToast(result.error, 'error'); + } else { + showToast('약국 정보가 수정되었습니다.', 'success'); + pharmacyModal.hide(); + setTimeout(() => location.reload(), 1000); + } + }) + .catch(error => { + console.error('약국 정보 수정 실패:', error); + showToast('약국 정보 수정에 실패했습니다.', 'error'); + }); + } else { + // 새 등록 모드: POST 요청 (향후 구현) + showToast('새 약국 등록 기능은 아직 구현 중입니다.', 'warning'); + pharmacyModal.hide(); + } }); // 테이블 정렬 및 검색 기능 추가 (향후) diff --git a/farmq-admin/utils/database_new.py b/farmq-admin/utils/database_new.py index 8918dc4..4bc3306 100644 --- a/farmq-admin/utils/database_new.py +++ b/farmq-admin/utils/database_new.py @@ -68,14 +68,16 @@ def get_dashboard_stats() -> Dict[str, Any]: PharmacyInfo.status == 'active' ).count() - # 머신 상태 - total_machines = farmq_session.query(MachineProfile).filter( - MachineProfile.status == 'active' - ).count() + # 머신 상태 - Headscale 노드 기준으로 계산 + from models.headscale_models import Node - online_machines = farmq_session.query(MachineProfile).filter( - MachineProfile.status == 'active', - MachineProfile.tailscale_status == 'online' + # 전체 머신 수 (Headscale 노드 기준) + total_machines = headscale_session.query(Node).count() + + # 온라인 머신 수 (last_seen이 최근 5분 이내) + cutoff_time = datetime.now() - timedelta(minutes=5) + online_machines = headscale_session.query(Node).filter( + Node.last_seen > cutoff_time ).count() offline_machines = total_machines - online_machines @@ -226,6 +228,8 @@ def get_all_machines_with_details() -> List[Dict[str, Any]]: Node.deleted_at.is_(None) ).all() + print(f"🔍 Found {len(nodes)} nodes in Headscale database") + result = [] for node in nodes: # 기본 머신 정보