headscale-tailscale-replace.../farmq-admin/models/farmq_models.py
시골약사 ca61a89739 🏥 Add complete FARMQ Admin Flask application
## Features
- 한국어 Flask 관리 인터페이스 with Bootstrap 5
- Headscale과 분리된 독립 데이터베이스 구조
- 약국 관리 시스템 (pharmacy management)
- 머신 모니터링 및 상태 관리
- 실시간 대시보드 with 통계 및 알림
- Headscale 사용자명과 약국명 분리 관리

## Database Architecture
- 별도 FARMQ SQLite DB (farmq.sqlite)
- Headscale DB와 외래키 충돌 방지
- 느슨한 결합 설계 (ID 참조만 사용)

## UI Components
- 반응형 대시보드 with 실시간 통계
- 약국별 머신 상태 모니터링
- 한국어 지역화 및 사용자 친화적 인터페이스
- 머신 온라인/오프라인 상태 표시 (24시간 타임아웃)

## API Endpoints
- `/api/sync/machines` - Headscale 머신 동기화
- `/api/sync/users` - Headscale 사용자 동기화
- `/api/pharmacy/<id>/update` - 약국 정보 업데이트
- 대시보드 통계 및 알림 API

## Problem Resolution
- Fixed foreign key conflicts preventing Windows client connections
- Resolved machine online status detection with proper timeout handling
- Separated technical Headscale usernames from business pharmacy names

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 17:44:56 +09:00

510 lines
20 KiB
Python

"""
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"<PharmacyInfo(id={self.id}, name='{self.pharmacy_name}', status='{self.status}')>"
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"<MachineProfile(id={self.id}, hostname='{self.hostname}', cpu='{self.cpu_model}')>"
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"<MonitoringMetrics(machine_id={self.machine_profile_id}, cpu={self.cpu_usage_percent}%, collected_at='{self.collected_at}')>"
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"<SystemAlert(id={self.id}, type='{self.alert_type}', severity='{self.severity}', status='{self.status}')>"
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.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('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}")