## 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>
510 lines
20 KiB
Python
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}") |