""" FARMQ 독립적인 모델 설계 Headscale과 충돌하지 않는 별도 데이터베이스 사용 설계 원칙: 1. 별도 데이터베이스 사용 (farmq.sqlite) 2. Headscale 테이블과 직접적인 외래키 제약조건 제거 3. 느슨한 결합: ID 참조만 사용 (외래키 제약조건 없음) 4. 능동적 대응: 데이터 무결성을 애플리케이션 레벨에서 관리 """ from datetime import datetime, timedelta from typing import Optional, List, Dict, Any import json from sqlalchemy import ( Column, Integer, String, DateTime, Boolean, Text, Float, Index, UniqueConstraint, create_engine ) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker, Session from sqlalchemy.types import TypeDecorator, TEXT # FARMQ 전용 Base 클래스 FarmqBase = declarative_base() class JSONType(TypeDecorator): """Custom JSON type for SQLAlchemy""" impl = TEXT def process_bind_param(self, value, dialect): if value is not None: return json.dumps(value) return value def process_result_value(self, value, dialect): if value is not None: return json.loads(value) return value class PharmacyInfo(FarmqBase): """약국 정보 테이블 - Headscale과 독립적""" __tablename__ = 'pharmacies' id = Column(Integer, primary_key=True, autoincrement=True) # Headscale 연결 정보 (느슨한 결합) headscale_user_name = Column(String(255)) # users.name 참조 (외래키 제약조건 없음) headscale_user_id = Column(Integer) # users.id 참조 (외래키 제약조건 없음) # 약국 기본 정보 pharmacy_name = Column(String(255), nullable=False) business_number = Column(String(20)) manager_name = Column(String(100)) phone = Column(String(20)) address = Column(Text) # 기술적 정보 proxmox_host = Column(String(255)) proxmox_username = Column(String(100)) proxmox_api_token = Column(Text) # 암호화 권장 tailscale_ip = Column(String(45)) # IPv4/IPv6 지원 # 상태 관리 status = Column(String(20), default='active') # active, inactive, maintenance last_sync = Column(DateTime) # 마지막 동기화 시간 notes = Column(Text) # 관리 메모 # 타임스탬프 created_at = Column(DateTime, default=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) def __repr__(self): return f"" def to_dict(self) -> Dict[str, Any]: """딕셔너리로 변환""" return { 'id': self.id, 'headscale_user_name': self.headscale_user_name, 'headscale_user_id': self.headscale_user_id, 'pharmacy_name': self.pharmacy_name, 'business_number': self.business_number, 'manager_name': self.manager_name, 'phone': self.phone, 'address': self.address, 'proxmox_host': self.proxmox_host, 'tailscale_ip': self.tailscale_ip, 'status': self.status, 'last_sync': self.last_sync.isoformat() if self.last_sync else None, 'created_at': self.created_at.isoformat(), 'updated_at': self.updated_at.isoformat() } class MachineProfile(FarmqBase): """머신 프로필 테이블 - 하드웨어 스펙 및 구성""" __tablename__ = 'machine_profiles' id = Column(Integer, primary_key=True, autoincrement=True) # Headscale 연결 정보 (느슨한 결합) headscale_node_id = Column(Integer) # nodes.id 참조 (외래키 제약조건 없음) headscale_machine_key = Column(String(255)) # nodes.machine_key 참조 pharmacy_id = Column(Integer) # pharmacies.id 참조 (외래키 제약조건 없음) # 머신 식별 정보 hostname = Column(String(255)) machine_name = Column(String(255)) # 사용자 정의 머신명 serial_number = Column(String(100)) # 하드웨어 시리얼 번호 # 하드웨어 스펙 cpu_model = Column(String(255)) cpu_cores = Column(Integer) cpu_threads = Column(Integer) ram_gb = Column(Integer) storage_type = Column(String(50)) # SSD, HDD, NVMe storage_gb = Column(Integer) gpu_model = Column(String(255)) gpu_memory_gb = Column(Integer) # 네트워크 정보 network_interfaces = Column(JSONType) # 네트워크 인터페이스 목록 tailscale_ip = Column(String(45)) tailscale_status = Column(String(20), default='unknown') # online, offline, unknown # 운영체제 및 소프트웨어 os_type = Column(String(50)) # Windows, Linux, etc. os_version = Column(String(100)) tailscale_version = Column(String(50)) installed_software = Column(JSONType) # 설치된 소프트웨어 목록 # 상태 및 관리 status = Column(String(20), default='active') # active, maintenance, retired location = Column(String(255)) # 물리적 위치 purchase_date = Column(DateTime) warranty_expires = Column(DateTime) last_maintenance = Column(DateTime) # 성능 기준선 baseline_cpu_temp = Column(Float) # 정상 CPU 온도 기준 baseline_cpu_usage = Column(Float) # 정상 CPU 사용률 기준 baseline_memory_usage = Column(Float) # 정상 메모리 사용률 기준 # 타임스탬프 created_at = Column(DateTime, default=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) last_seen = Column(DateTime) # 마지막 활동 시간 def __repr__(self): return f"" def is_online(self, timeout_minutes: int = 10) -> bool: """온라인 상태 확인""" if not self.last_seen: return False return (datetime.now() - self.last_seen).total_seconds() < (timeout_minutes * 60) def to_dict(self) -> Dict[str, Any]: return { 'id': self.id, 'headscale_node_id': self.headscale_node_id, 'pharmacy_id': self.pharmacy_id, 'hostname': self.hostname, 'machine_name': self.machine_name, 'cpu_model': self.cpu_model, 'cpu_cores': self.cpu_cores, 'ram_gb': self.ram_gb, 'storage_gb': self.storage_gb, 'tailscale_ip': self.tailscale_ip, 'tailscale_status': self.tailscale_status, 'os_type': self.os_type, 'os_version': self.os_version, 'status': self.status, 'is_online': self.is_online(), 'created_at': self.created_at.isoformat(), 'last_seen': self.last_seen.isoformat() if self.last_seen else None } class MonitoringMetrics(FarmqBase): """실시간 모니터링 메트릭스 - 시계열 데이터""" __tablename__ = 'monitoring_metrics' __table_args__ = ( Index('idx_machine_timestamp', 'machine_profile_id', 'collected_at'), Index('idx_collected_at', 'collected_at'), ) id = Column(Integer, primary_key=True, autoincrement=True) # 연결 정보 machine_profile_id = Column(Integer) # machine_profiles.id 참조 (외래키 제약조건 없음) headscale_node_id = Column(Integer) # 빠른 조회를 위한 중복 저장 # 시스템 메트릭스 cpu_usage_percent = Column(Float) # CPU 사용률 memory_usage_percent = Column(Float) # 메모리 사용률 memory_used_gb = Column(Float) # 사용 중인 메모리 (GB) memory_total_gb = Column(Float) # 총 메모리 (GB) # 스토리지 메트릭스 disk_usage_percent = Column(Float) # 디스크 사용률 disk_used_gb = Column(Float) # 사용 중인 디스크 (GB) disk_total_gb = Column(Float) # 총 디스크 (GB) disk_io_read_mb = Column(Float) # 디스크 읽기 (MB/s) disk_io_write_mb = Column(Float) # 디스크 쓰기 (MB/s) # 온도 및 전력 cpu_temperature = Column(Float) # CPU 온도 (섭씨) gpu_temperature = Column(Float) # GPU 온도 (섭씨) system_temperature = Column(Float) # 시스템 온도 power_consumption_watts = Column(Float) # 전력 소모 (와트) # 네트워크 메트릭스 network_rx_bytes_sec = Column(Integer) # 네트워크 수신 (bytes/sec) network_tx_bytes_sec = Column(Integer) # 네트워크 송신 (bytes/sec) network_latency_ms = Column(Float) # 네트워크 지연시간 (ms) # 프로세스 및 서비스 process_count = Column(Integer) # 실행 중인 프로세스 수 service_status = Column(JSONType) # 중요 서비스 상태 # 가상머신 관련 (Proxmox) vm_count_total = Column(Integer) # 총 VM 수 vm_count_running = Column(Integer) # 실행 중인 VM 수 vm_count_stopped = Column(Integer) # 중지된 VM 수 # 상태 및 알림 alert_level = Column(String(10), default='normal') # normal, warning, critical alert_message = Column(Text) # 알림 메시지 # 타임스탬프 collected_at = Column(DateTime, default=datetime.now) def __repr__(self): return f"" def to_dict(self) -> Dict[str, Any]: return { 'id': self.id, 'machine_profile_id': self.machine_profile_id, 'cpu_usage_percent': self.cpu_usage_percent, 'memory_usage_percent': self.memory_usage_percent, 'disk_usage_percent': self.disk_usage_percent, 'cpu_temperature': self.cpu_temperature, 'network_rx_bytes_sec': self.network_rx_bytes_sec, 'network_tx_bytes_sec': self.network_tx_bytes_sec, 'alert_level': self.alert_level, 'collected_at': self.collected_at.isoformat() } def get_alert_status(self) -> Dict[str, Any]: """알림 상태 및 메시지 반환""" alerts = [] # CPU 온도 체크 if self.cpu_temperature and self.cpu_temperature > 80: alerts.append({'type': 'temperature', 'message': f'CPU 온도 높음: {self.cpu_temperature}°C'}) # CPU 사용률 체크 if self.cpu_usage_percent and self.cpu_usage_percent > 90: alerts.append({'type': 'cpu', 'message': f'CPU 사용률 높음: {self.cpu_usage_percent}%'}) # 메모리 사용률 체크 if self.memory_usage_percent and self.memory_usage_percent > 85: alerts.append({'type': 'memory', 'message': f'메모리 사용률 높음: {self.memory_usage_percent}%'}) # 디스크 사용률 체크 if self.disk_usage_percent and self.disk_usage_percent > 90: alerts.append({'type': 'disk', 'message': f'디스크 사용률 높음: {self.disk_usage_percent}%'}) return { 'level': 'critical' if any(alert['type'] in ['temperature', 'cpu'] for alert in alerts) else 'warning' if alerts else 'normal', 'alerts': alerts, 'count': len(alerts) } class SystemAlert(FarmqBase): """시스템 알림 테이블""" __tablename__ = 'system_alerts' __table_args__ = ( Index('idx_alert_status', 'status'), Index('idx_alert_created', 'created_at'), Index('idx_alert_severity', 'severity'), ) id = Column(Integer, primary_key=True, autoincrement=True) # 연결 정보 machine_profile_id = Column(Integer) # machine_profiles.id 참조 pharmacy_id = Column(Integer) # pharmacies.id 참조 # 알림 정보 alert_type = Column(String(50)) # cpu, memory, disk, temperature, network, service severity = Column(String(10)) # low, medium, high, critical title = Column(String(255)) # 알림 제목 message = Column(Text) # 알림 상세 메시지 # 메트릭스 값 current_value = Column(Float) # 현재 값 threshold_value = Column(Float) # 임계값 unit = Column(String(10)) # 단위 (%, GB, °C, etc.) # 상태 관리 status = Column(String(20), default='active') # active, acknowledged, resolved acknowledged_by = Column(String(100)) # 확인한 사용자 acknowledged_at = Column(DateTime) # 확인 시간 resolved_at = Column(DateTime) # 해결 시간 # 반복 방지 fingerprint = Column(String(255)) # 중복 알림 방지용 핑거프린트 occurrence_count = Column(Integer, default=1) # 발생 횟수 first_occurred = Column(DateTime, default=datetime.now) # 최초 발생 시간 last_occurred = Column(DateTime, default=datetime.now) # 최근 발생 시간 # 타임스탬프 created_at = Column(DateTime, default=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) def __repr__(self): return f"" def acknowledge(self, user: str = 'system'): """알림 확인 처리""" self.status = 'acknowledged' self.acknowledged_by = user self.acknowledged_at = datetime.now() def resolve(self): """알림 해결 처리""" self.status = 'resolved' self.resolved_at = datetime.now() def to_dict(self) -> Dict[str, Any]: return { 'id': self.id, 'machine_profile_id': self.machine_profile_id, 'pharmacy_id': self.pharmacy_id, 'alert_type': self.alert_type, 'severity': self.severity, 'title': self.title, 'message': self.message, 'current_value': self.current_value, 'threshold_value': self.threshold_value, 'unit': self.unit, 'status': self.status, 'occurrence_count': self.occurrence_count, 'created_at': self.created_at.isoformat(), 'last_occurred': self.last_occurred.isoformat() } # ========================================== # Database Manager Class # ========================================== class FarmqDatabaseManager: """FARMQ 데이터베이스 관리 클래스""" def __init__(self, database_url: str = "sqlite:///farmq-admin/farmq.sqlite"): self.database_url = database_url self.engine = create_engine(database_url, echo=False) self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine) self._create_tables() def _create_tables(self): """테이블 생성""" FarmqBase.metadata.create_all(self.engine) def get_session(self) -> Session: """세션 생성""" return self.SessionLocal() def close_session(self, session: Session): """세션 종료""" session.close() # ========================================== # Pharmacy Management # ========================================== def get_pharmacy_by_headscale_user(self, headscale_user_name: str) -> Optional[PharmacyInfo]: """Headscale 사용자명으로 약국 정보 조회""" session = self.get_session() try: return session.query(PharmacyInfo).filter( PharmacyInfo.headscale_user_name == headscale_user_name ).first() finally: session.close() def create_or_update_pharmacy(self, pharmacy_data: Dict[str, Any]) -> PharmacyInfo: """약국 정보 생성 또는 업데이트""" session = self.get_session() try: pharmacy = session.query(PharmacyInfo).filter( PharmacyInfo.headscale_user_name == pharmacy_data.get('headscale_user_name') ).first() if pharmacy: # 업데이트 for key, value in pharmacy_data.items(): if hasattr(pharmacy, key): setattr(pharmacy, key, value) pharmacy.updated_at = datetime.now() else: # 생성 pharmacy = PharmacyInfo(**pharmacy_data) session.add(pharmacy) session.commit() session.refresh(pharmacy) return pharmacy finally: session.close() # ========================================== # Machine Management # ========================================== def sync_machine_from_headscale(self, headscale_node_data: Dict[str, Any]) -> MachineProfile: """Headscale 노드 데이터로 머신 프로필 동기화""" session = self.get_session() try: machine = session.query(MachineProfile).filter( MachineProfile.headscale_node_id == headscale_node_data.get('id') ).first() if machine: # 기존 머신 업데이트 machine.hostname = headscale_node_data.get('hostname') machine.machine_name = headscale_node_data.get('given_name') or headscale_node_data.get('hostname') machine.tailscale_ip = headscale_node_data.get('ipv4') machine.tailscale_status = 'online' if headscale_node_data.get('is_online') else 'offline' machine.last_seen = datetime.now() machine.updated_at = datetime.now() else: # 새 머신 생성 machine = MachineProfile( headscale_node_id=headscale_node_data.get('id'), headscale_machine_key=headscale_node_data.get('machine_key'), hostname=headscale_node_data.get('hostname'), machine_name=headscale_node_data.get('given_name') or headscale_node_data.get('hostname'), tailscale_ip=headscale_node_data.get('ipv4'), tailscale_status='online' if headscale_node_data.get('is_online') else 'offline', last_seen=datetime.now() ) session.add(machine) session.commit() session.refresh(machine) return machine finally: session.close() def get_machine_stats(self) -> Dict[str, int]: """머신 통계 조회""" session = self.get_session() try: total = session.query(MachineProfile).count() online = session.query(MachineProfile).filter( MachineProfile.tailscale_status == 'online' ).count() return { 'total': total, 'online': online, 'offline': total - online } finally: session.close() # ========================================== # Factory Function # ========================================== def create_farmq_database_manager(database_url: str = None) -> FarmqDatabaseManager: """FARMQ 데이터베이스 매니저 생성""" if database_url is None: database_url = "sqlite:///farmq.sqlite" return FarmqDatabaseManager(database_url) if __name__ == "__main__": # 테스트 실행 manager = create_farmq_database_manager() print("✅ FARMQ 데이터베이스 매니저 생성 완료") print(f"📊 데이터베이스 URL: {manager.database_url}") # 테스트 데이터 생성 test_pharmacy = { 'headscale_user_name': 'test-pharmacy', 'pharmacy_name': '테스트 약국', 'business_number': '123-45-67890', 'manager_name': '김약사', 'phone': '02-1234-5678', 'address': '서울특별시 강남구 테스트동 123', 'proxmox_host': '192.168.1.100' } pharmacy = manager.create_or_update_pharmacy(test_pharmacy) print(f"✅ 테스트 약국 생성: {pharmacy}") stats = manager.get_machine_stats() print(f"📈 머신 통계: {stats}")