headscale-tailscale-replace.../farmq-admin/app.py
시골약사 7aa08682b8 Implement smart Magic DNS copy with automatic port detection
### Magic DNS Smart Copy Features:
- **PBS servers**: Automatically append `:8007` port when copying
- **PVE servers**: Automatically append `:8006` port when copying
- **Other machines**: Copy Magic DNS address without port (existing behavior)

### UI Improvements:
- PBS servers: Blue button with `:8007` port hint
- PVE servers: Orange button with `:8006` port hint
- Enhanced tooltips with service-specific port information
- Visual distinction between different server types

### PBS Backup Server Monitoring:
- Complete PBS API integration with authentication
- Real-time backup/restore task monitoring with detailed logs
- Namespace-separated backup visualization with color coding
- Datastore usage monitoring and status tracking
- Task history with success/failure status and error details

### Technical Implementation:
- Smart port detection via JavaScript `addSmartPort()` function
- Jinja2 template logic for conditional button styling
- PBS API endpoints for comprehensive backup monitoring
- Enhanced clipboard functionality with user feedback

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 10:48:47 +09:00

2270 lines
89 KiB
Python

#!/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/<int:pharmacy_id>')
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/<int:machine_id>')
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/<int:machine_id>/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/<int:machine_id>/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/<int:pharmacy_id>', 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/<int:pharmacy_id>', 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/<int:pharmacy_id>/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/<user_name>/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/<session_id>', 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/<session_id>')
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/<session_id>/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/<session_id>/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/<int:node_id>/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/<user_name>/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/<int:pharmacy_id>/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/<int:subscription_id>', 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/<datastore_name>')
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/<datastore_name>/<backup_type>/<backup_id>')
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/<task_id>')
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/<task_id>')
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
)