headscale-tailscale-replace.../farmq-admin/utils/database_new.py
시골약사 a9aa31cc4a Implement FarmQ Admin machine name display fix for Magic DNS
Fix machine management page to display proper Magic DNS names:
- Use given_name instead of hostname for machine display
- Add Magic DNS address with copy-to-clipboard functionality
- Distinguish between machine name and OS hostname like Headplane
- Enhance UI with Magic DNS information (.headscale.local)

Changes:
- farmq-admin/utils/database_new.py: Use given_name for machine_name
- farmq-admin/models/farmq_models.py: Update sync logic for given_name
- farmq-admin/templates/machines/list.html: Add Magic DNS display with copy feature
- FARMQ_ADMIN_MACHINE_NAME_FIX_PLAN.md: Complete analysis and implementation plan

Now displays:
- Machine Name: pbs-hp (Magic DNS name)
- Magic DNS: pbs-hp.headscale.local (with copy button)
- OS Hostname: proxmox-backup-server (system name)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:28:20 +09:00

752 lines
28 KiB
Python

"""
새로운 데이터베이스 유틸리티 - Headscale과 분리된 FARMQ 전용
외래키 제약조건 없이 능동적으로 데이터를 관리
"""
import os
import json
import subprocess
from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta
from sqlalchemy import create_engine, text, and_, or_, desc
from sqlalchemy.orm import sessionmaker, Session
from models.farmq_models import (
PharmacyInfo, MachineProfile, MonitoringMetrics, SystemAlert,
FarmqDatabaseManager, create_farmq_database_manager,
FarmqBase
)
from models.headscale_models import User, Node, PreAuthKey, ApiKey
# 전역 데이터베이스 매니저들
farmq_manager: Optional[FarmqDatabaseManager] = None
headscale_engine = None
headscale_session_maker = None
def init_databases(headscale_db_uri: str, farmq_db_uri: str = None):
"""두 개의 데이터베이스 초기화"""
global farmq_manager, headscale_engine, headscale_session_maker
# FARMQ 전용 데이터베이스 (외래키 제약조건 없음)
if farmq_db_uri is None:
farmq_db_uri = "sqlite:///farmq-admin/farmq.sqlite"
farmq_manager = create_farmq_database_manager(farmq_db_uri)
print(f"✅ FARMQ 데이터베이스 초기화: {farmq_db_uri}")
# Headscale 읽기 전용 데이터베이스
headscale_engine = create_engine(headscale_db_uri, echo=False)
headscale_session_maker = sessionmaker(bind=headscale_engine)
print(f"✅ Headscale 데이터베이스 연결: {headscale_db_uri}")
def get_farmq_session() -> Session:
"""FARMQ 데이터베이스 세션 가져오기"""
if farmq_manager is None:
raise RuntimeError("FARMQ database not initialized")
return farmq_manager.get_session()
def get_headscale_session() -> Session:
"""Headscale 데이터베이스 세션 가져오기 (읽기 전용)"""
if headscale_session_maker is None:
raise RuntimeError("Headscale database not initialized")
return headscale_session_maker()
def close_session(session: Session):
"""세션 종료"""
if session:
session.close()
# ==========================================
# Headscale CLI Integration
# ==========================================
def get_headscale_online_status() -> Dict[str, bool]:
"""Headscale CLI를 통해 실시간 온라인 상태 조회"""
try:
# Docker를 통해 Headscale CLI 실행
result = subprocess.run(
['docker', 'exec', 'headscale', 'headscale', 'nodes', 'list', '-o', 'json'],
capture_output=True,
text=True,
check=True
)
nodes_data = json.loads(result.stdout)
online_status = {}
for node in nodes_data:
# given_name 또는 name 사용
node_name = node.get('given_name') or node.get('name', '')
# online 필드가 True인 경우만 온라인
# 필드가 없거나 False면 오프라인
is_online = node.get('online', False) == True
online_status[node_name.lower()] = is_online
return online_status
except Exception as e:
print(f"❌ Headscale CLI 호출 실패: {e}")
return {}
# ==========================================
# Dashboard Statistics
# ==========================================
def get_dashboard_stats() -> Dict[str, Any]:
"""대시보드 통계 조회 - 실시간 DB 쿼리"""
farmq_session = get_farmq_session()
headscale_session = get_headscale_session()
try:
# 약국 수
total_pharmacies = farmq_session.query(PharmacyInfo).filter(
PharmacyInfo.status == 'active'
).count()
# 머신 상태 - Headscale 노드에서 직접 실시간 조회
from models.headscale_models import Node
# 활성 노드만 조회 (deleted_at이 null인 것)
active_nodes = headscale_session.query(Node).filter(
Node.deleted_at.is_(None)
).all()
total_machines = len(active_nodes)
# Headscale CLI를 통해 실시간 온라인 상태 가져오기
online_status = get_headscale_online_status()
# 온라인 머신 수 계산
online_machines = 0
print(f"🔍 Headscale CLI 온라인 상태:")
for node in active_nodes:
node_name = (node.given_name or node.hostname or '').lower()
is_online = online_status.get(node_name, False)
if is_online:
online_machines += 1
print(f" {node.given_name:15s} | {'🟢 ONLINE' if is_online else '🔴 OFFLINE'}")
offline_machines = total_machines - online_machines
print(f"📊 최종 카운트: {online_machines}/{total_machines} 온라인 (Headplane과 동일)")
# 최근 알림 수
recent_alerts = farmq_session.query(SystemAlert).filter(
SystemAlert.status == 'active',
SystemAlert.created_at > (datetime.now() - timedelta(hours=24))
).count()
# 평균 CPU 온도 (최근 1시간)
cutoff_time = datetime.now() - timedelta(hours=1)
avg_temp_result = farmq_session.query(
MonitoringMetrics.cpu_temperature
).filter(
MonitoringMetrics.collected_at > cutoff_time,
MonitoringMetrics.cpu_temperature.isnot(None)
).all()
avg_cpu_temp = 0
if avg_temp_result:
temps = [temp[0] for temp in avg_temp_result if temp[0] is not None]
avg_cpu_temp = sum(temps) / len(temps) if temps else 0
return {
'total_pharmacies': total_pharmacies,
'total_machines': total_machines,
'online_machines': online_machines,
'offline_machines': offline_machines,
'recent_alerts': recent_alerts,
'avg_cpu_temp': round(avg_cpu_temp, 1)
}
finally:
close_session(farmq_session)
close_session(headscale_session)
# ==========================================
# Pharmacy Management
# ==========================================
def get_all_pharmacies_with_stats() -> List[Dict[str, Any]]:
"""모든 약국과 통계 정보 조회 - Headscale Node 데이터 사용"""
farmq_session = get_farmq_session()
headscale_session = get_headscale_session()
try:
pharmacies = farmq_session.query(PharmacyInfo).filter(
PharmacyInfo.status == 'active'
).all()
# Headscale CLI를 통해 실시간 온라인 상태 가져오기
online_status = get_headscale_online_status()
result = []
for pharmacy in pharmacies:
# Headscale에서 해당 사용자의 머신 수 조회
user_machines = headscale_session.query(Node).join(User).filter(
User.name == pharmacy.headscale_user_name,
Node.deleted_at.is_(None)
).all()
machine_count = len(user_machines)
# 온라인 머신 수 계산 - Headscale CLI 기반
online_count = 0
for machine in user_machines:
node_name = (machine.given_name or machine.hostname or '').lower()
if online_status.get(node_name, False):
online_count += 1
# 활성 알림 수 (현재는 0으로 설정, 나중에 구현)
alert_count = 0
pharmacy_data = pharmacy.to_dict()
pharmacy_data.update({
'machine_count': machine_count,
'online_count': online_count,
'offline_count': machine_count - online_count,
'alert_count': alert_count
})
result.append(pharmacy_data)
return result
finally:
close_session(farmq_session)
close_session(headscale_session)
def get_pharmacy_detail(pharmacy_id: int) -> Optional[Dict[str, Any]]:
"""Headscale CLI 기반 약국 상세 정보 조회"""
import subprocess
import json
from datetime import datetime
farmq_session = get_farmq_session()
try:
# 약국 기본 정보 조회
pharmacy = farmq_session.query(PharmacyInfo).filter(
PharmacyInfo.id == pharmacy_id
).first()
if not pharmacy:
return None
# Headscale CLI에서 노드 목록 가져오기
result = subprocess.run(
['docker', 'exec', 'headscale', 'headscale', 'nodes', 'list', '-o', 'json'],
capture_output=True,
text=True,
check=True
)
nodes_data = json.loads(result.stdout)
online_status = get_headscale_online_status()
# 약국과 연결된 사용자의 머신들 찾기
machine_list = []
if pharmacy.headscale_user_name:
for node in nodes_data:
# 이 노드가 약국의 사용자 것인지 확인
node_user_name = node.get('user', {}).get('name', '')
if node_user_name == pharmacy.headscale_user_name:
# 시간 변환 함수
def convert_timestamp(ts_obj):
if isinstance(ts_obj, dict) and 'seconds' in ts_obj:
return datetime.fromtimestamp(ts_obj['seconds'])
elif isinstance(ts_obj, str):
try:
return datetime.fromisoformat(ts_obj.replace('Z', '+00:00'))
except:
return None
return None
node_name = node.get('given_name') or node.get('name', '')
is_online = online_status.get(node_name.lower(), False)
machine_data = {
'id': node.get('id'),
'given_name': node.get('given_name'),
'hostname': node.get('name'),
'ipv4': node.get('ip_addresses', [])[0] if node.get('ip_addresses') else None,
'ipv6': node.get('ip_addresses', [])[1] if len(node.get('ip_addresses', [])) > 1 else None,
'machine_key': node.get('machine_key'),
'node_key': node.get('node_key'),
'disco_key': node.get('disco_key'),
'user': node.get('user'),
'last_seen': convert_timestamp(node.get('last_seen')),
'created_at': convert_timestamp(node.get('created_at')),
'register_method': 'CLI' if node.get('register_method') == 1 else 'Pre-auth Key',
'online': is_online,
'endpoints': node.get('endpoints', []),
}
machine_list.append(machine_data)
# 약국 정보에 통계 추가
pharmacy_data = {
'id': pharmacy.id,
'pharmacy_name': pharmacy.pharmacy_name,
'business_number': pharmacy.business_number,
'manager_name': pharmacy.manager_name,
'phone': pharmacy.phone,
'address': pharmacy.address,
'proxmox_host': pharmacy.proxmox_host,
'headscale_user_name': pharmacy.headscale_user_name,
'headscale_user_id': pharmacy.headscale_user_id,
'created_at': pharmacy.created_at,
'updated_at': pharmacy.updated_at,
# 통계 정보
'total_machines': len(machine_list),
'online_machines': len([m for m in machine_list if m['online']]),
'offline_machines': len([m for m in machine_list if not m['online']]),
}
return {
'pharmacy': pharmacy_data,
'machines': machine_list
}
except subprocess.CalledProcessError as e:
print(f"❌ Headscale CLI 오류: {e}")
return None
except Exception as e:
print(f"❌ 약국 상세 정보 조회 오류: {e}")
return None
finally:
farmq_session.close()
# ==========================================
# Machine Management
# ==========================================
def get_all_machines_with_details() -> List[Dict[str, Any]]:
"""모든 머신 상세 정보 조회 - Headscale Node 데이터 사용"""
headscale_session = get_headscale_session()
farmq_session = get_farmq_session()
try:
# Headscale에서 모든 노드 조회
nodes = headscale_session.query(Node).filter(
Node.deleted_at.is_(None)
).all()
print(f"🔍 Found {len(nodes)} nodes in Headscale database")
# Headscale CLI를 통해 실시간 온라인 상태 가져오기
online_status = get_headscale_online_status()
result = []
for node in nodes:
# 기본 머신 정보
machine_data = {
'id': node.id,
'hostname': node.hostname,
'machine_name': node.given_name or node.hostname, # Magic DNS용 이름 (given_name 우선)
'tailscale_ip': node.ipv4,
'ipv6': node.ipv6,
'headscale_user_name': node.user.name if node.user else '미지정',
'user_id': node.user_id,
'last_seen': node.last_seen,
'created_at': node.created_at,
'updated_at': node.updated_at
}
# Headscale CLI 기반 온라인 상태 확인
node_name = (node.given_name or node.hostname or '').lower()
machine_data['is_online'] = online_status.get(node_name, False)
# 사용자 이름으로 약국 정보 찾기
machine_data['pharmacy'] = None
if node.user:
pharmacy = farmq_session.query(PharmacyInfo).filter(
PharmacyInfo.headscale_user_name == node.user.name
).first()
if pharmacy:
machine_data['pharmacy'] = {
'id': pharmacy.id,
'pharmacy_name': pharmacy.pharmacy_name,
'manager_name': pharmacy.manager_name,
'business_number': pharmacy.business_number
}
# 마지막 접속 시간을 사람이 읽기 쉬운 형태로
machine_data['last_seen_humanized'] = humanize_datetime(node.last_seen)
# 하드웨어 사양 및 모니터링 데이터는 나중에 추가 예정
machine_data['specs'] = None
machine_data['latest_monitoring'] = None
result.append(machine_data)
return result
finally:
close_session(farmq_session)
close_session(headscale_session)
def get_machine_detail(machine_id: int) -> Optional[Dict[str, Any]]:
"""Headscale CLI를 통한 머신 상세 정보 조회"""
import subprocess
import json
from datetime import datetime
try:
# Headscale CLI에서 노드 목록 가져오기
result = subprocess.run(
['docker', 'exec', 'headscale', 'headscale', 'nodes', 'list', '-o', 'json'],
capture_output=True,
text=True,
check=True
)
nodes_data = json.loads(result.stdout)
# machine_id로 노드 찾기 (id 기준)
machine = None
for node in nodes_data:
if node.get('id') == machine_id:
machine = node
break
if not machine:
return None
# 온라인 상태 체크
online_status = get_headscale_online_status()
node_name = machine.get('given_name') or machine.get('name', '')
is_online = online_status.get(node_name.lower(), False)
# 시간 변환 함수
def convert_timestamp(ts_obj):
if isinstance(ts_obj, dict) and 'seconds' in ts_obj:
return datetime.fromtimestamp(ts_obj['seconds'])
elif isinstance(ts_obj, str):
try:
return datetime.fromisoformat(ts_obj.replace('Z', '+00:00'))
except:
return None
return None
# 엔드포인트 추출
endpoints = []
if 'endpoints' in machine:
endpoints = machine['endpoints']
# 약국 정보 매핑 (사용자명을 통해)
pharmacy_info = None
user_name = machine.get('user', {}).get('name', '')
if user_name:
farmq_session = get_farmq_session()
try:
pharmacy = farmq_session.query(PharmacyInfo).filter(
PharmacyInfo.headscale_user_name == user_name
).first()
if pharmacy:
pharmacy_info = {
'id': pharmacy.id,
'name': pharmacy.pharmacy_name,
'manager': pharmacy.manager_name,
'address': pharmacy.address
}
finally:
farmq_session.close()
# 반환 데이터 구성
machine_data = {
'id': machine.get('id'),
'given_name': machine.get('given_name'),
'hostname': machine.get('name'),
'ipv4': machine.get('ip_addresses', [])[0] if machine.get('ip_addresses') else None,
'ipv6': machine.get('ip_addresses', [])[1] if len(machine.get('ip_addresses', [])) > 1 else None,
'machine_key': machine.get('machine_key'),
'node_key': machine.get('node_key'),
'disco_key': machine.get('disco_key'),
'user': machine.get('user'),
'last_seen': convert_timestamp(machine.get('last_seen')),
'created_at': convert_timestamp(machine.get('created_at')),
'register_method': 'CLI' if machine.get('register_method') == 1 else 'Pre-auth Key',
'online': is_online,
'endpoints': endpoints,
'pharmacy': pharmacy_info,
# 헬퍼 메서드
'get_endpoints': lambda: endpoints,
}
return machine_data
except subprocess.CalledProcessError as e:
print(f"❌ Headscale CLI 오류: {e}")
return None
except Exception as e:
print(f"❌ 머신 상세 정보 조회 오류: {e}")
return None
# ==========================================
# Headscale Synchronization
# ==========================================
def sync_machines_from_headscale() -> Dict[str, int]:
"""Headscale에서 머신 정보 동기화"""
farmq_session = get_farmq_session()
headscale_session = get_headscale_session()
try:
# Headscale에서 모든 노드 조회
nodes = headscale_session.query(Node).filter(
Node.deleted_at.is_(None)
).all()
synced = 0
created = 0
for node in nodes:
# FARMQ 데이터베이스에서 해당 머신 찾기
machine = farmq_session.query(MachineProfile).filter(
MachineProfile.headscale_node_id == node.id
).first()
if machine:
# 기존 머신 업데이트
is_online = node.is_online()
status = 'online' if is_online else 'offline'
machine.hostname = node.hostname
machine.tailscale_ip = node.ipv4
machine.tailscale_status = status
machine.last_seen = node.last_seen or datetime.now()
machine.updated_at = datetime.now()
synced += 1
else:
# 새 머신 생성
machine = MachineProfile(
headscale_node_id=node.id,
headscale_machine_key=node.machine_key,
hostname=node.hostname or 'unknown',
machine_name=node.given_name or node.hostname or 'unknown',
tailscale_ip=node.ipv4,
tailscale_status='online' if node.is_online() else 'offline',
os_type='unknown',
status='active',
last_seen=node.last_seen or datetime.now()
)
farmq_session.add(machine)
created += 1
farmq_session.commit()
return {
'total_nodes': len(nodes),
'synced': synced,
'created': created
}
finally:
close_session(farmq_session)
close_session(headscale_session)
def sync_users_from_headscale() -> Dict[str, int]:
"""Headscale에서 사용자 정보 동기화"""
farmq_session = get_farmq_session()
headscale_session = get_headscale_session()
try:
# Headscale에서 모든 사용자 조회
users = headscale_session.query(User).filter(
User.deleted_at.is_(None)
).all()
synced = 0
created = 0
for user in users:
# FARMQ 데이터베이스에서 해당 약국 찾기
pharmacy = farmq_session.query(PharmacyInfo).filter(
PharmacyInfo.headscale_user_name == user.name
).first()
if pharmacy:
# 기존 약국 업데이트
pharmacy.headscale_user_id = user.id
# 약국명이 사용자명과 같으면 더 나은 이름으로 업데이트
if pharmacy.pharmacy_name == user.name:
if user.display_name and user.display_name != user.name:
pharmacy.pharmacy_name = user.display_name
else:
pharmacy.pharmacy_name = f"{user.name} 약국" # 더 나은 기본 이름
# 기본값들이 None인 경우 업데이트
if not pharmacy.business_number or pharmacy.business_number == "None":
pharmacy.business_number = "000-00-00000"
if not pharmacy.manager_name or pharmacy.manager_name == "None":
pharmacy.manager_name = "관리자"
pharmacy.last_sync = datetime.now()
pharmacy.updated_at = datetime.now()
synced += 1
else:
# 새 약국 생성 (기본 정보로)
pharmacy_name = user.display_name if user.display_name else f"{user.name} 약국"
pharmacy = PharmacyInfo(
headscale_user_name=user.name,
headscale_user_id=user.id,
pharmacy_name=pharmacy_name,
business_number="000-00-00000", # 기본 사업자번호
manager_name="관리자",
status='active',
last_sync=datetime.now()
)
farmq_session.add(pharmacy)
created += 1
farmq_session.commit()
return {
'total_users': len(users),
'synced': synced,
'created': created
}
finally:
close_session(farmq_session)
close_session(headscale_session)
# ==========================================
# Alert Management
# ==========================================
def get_active_alerts(limit: int = 50) -> List[Dict[str, Any]]:
"""활성 알림 조회"""
farmq_session = get_farmq_session()
try:
alerts = farmq_session.query(SystemAlert).filter(
SystemAlert.status == 'active'
).order_by(desc(SystemAlert.created_at)).limit(limit).all()
return [alert.to_dict() for alert in alerts]
finally:
close_session(farmq_session)
def create_alert(machine_profile_id: int, alert_data: Dict[str, Any]) -> SystemAlert:
"""새로운 알림 생성"""
farmq_session = get_farmq_session()
try:
# 중복 알림 확인
fingerprint = f"{machine_profile_id}_{alert_data.get('alert_type')}_{alert_data.get('current_value')}"
existing_alert = farmq_session.query(SystemAlert).filter(
SystemAlert.fingerprint == fingerprint,
SystemAlert.status == 'active'
).first()
if existing_alert:
# 기존 알림 업데이트
existing_alert.occurrence_count += 1
existing_alert.last_occurred = datetime.now()
existing_alert.updated_at = datetime.now()
farmq_session.commit()
return existing_alert
else:
# 새 알림 생성
alert = SystemAlert(
machine_profile_id=machine_profile_id,
fingerprint=fingerprint,
**alert_data
)
farmq_session.add(alert)
farmq_session.commit()
farmq_session.refresh(alert)
return alert
finally:
close_session(farmq_session)
# ==========================================
# Backward Compatibility Functions
# ==========================================
def get_pharmacy_count() -> int:
"""약국 수 조회 (하위 호환성)"""
stats = get_dashboard_stats()
return stats['total_pharmacies']
def get_online_machines_count() -> int:
"""온라인 머신 수 조회 (하위 호환성)"""
stats = get_dashboard_stats()
return stats['online_machines']
def get_offline_machines_count() -> int:
"""오프라인 머신 수 조회 (하위 호환성)"""
stats = get_dashboard_stats()
return stats['offline_machines']
def get_average_cpu_temperature() -> float:
"""평균 CPU 온도 조회 (하위 호환성)"""
stats = get_dashboard_stats()
return stats['avg_cpu_temp']
def humanize_datetime(dt) -> str:
"""datetime을 사람이 읽기 쉬운 형태로 변환"""
if not dt:
return '알 수 없음'
try:
import humanize
# 한국어 설정 시도
try:
humanize.i18n.activate('ko_KR')
except:
pass
return humanize.naturaltime(dt)
except ImportError:
# humanize가 없으면 기본 형식 사용
if isinstance(dt, str):
return dt
return dt.strftime('%Y-%m-%d %H:%M:%S')
def get_machine_with_details(machine_id: int) -> Optional[Dict[str, Any]]:
"""머신 상세 정보 조회 (하위 호환성)"""
return get_machine_detail(machine_id)
def get_performance_summary() -> Dict[str, Any]:
"""성능 요약 조회"""
return {
'status': 'good',
'summary': '모든 시스템이 정상 작동 중입니다.'
}
# ==========================================
# 초기화 함수
# ==========================================
def init_database(headscale_db_uri: str):
"""데이터베이스 초기화 (하위 호환성)"""
# FARMQ 데이터베이스는 자동으로 생성
farmq_db_uri = "sqlite:///farmq-admin/farmq.sqlite"
# 디렉토리 생성
os.makedirs("farmq-admin", exist_ok=True)
init_databases(headscale_db_uri, farmq_db_uri)
# 초기 동기화 실행
try:
print("🔄 Headscale에서 데이터 동기화 중...")
user_sync = sync_users_from_headscale()
machine_sync = sync_machines_from_headscale()
print(f"✅ 사용자 동기화: {user_sync}")
print(f"✅ 머신 동기화: {machine_sync}")
except Exception as e:
print(f"⚠️ 동기화 중 오류 발생: {e}")
def get_session():
"""FARMQ 세션 가져오기 (하위 호환성)"""
return get_farmq_session()
def close_session(session=None):
"""세션 종료 (하위 호환성)"""
if session:
session.close()
# 새로운 구조에서는 각 함수가 자체적으로 세션을 관리하므로 여기서는 아무것도 하지 않음