🏥 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>
This commit is contained in:
2025-09-09 17:44:56 +09:00
parent 9155bf5479
commit ca61a89739
16 changed files with 3824 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
"""
모델 패키지 초기화
Headscale 호환성 모델과 FARMQ 전용 모델 분리
"""
# Headscale 읽기 전용 모델 (외래키 제약조건 없음)
from .headscale_models import (
User, Node, PreAuthKey, ApiKey, Policy,
Base, create_all_tables
)
# FARMQ 전용 모델 (별도 데이터베이스)
from .farmq_models import (
PharmacyInfo, MachineProfile, MonitoringMetrics, SystemAlert,
FarmqDatabaseManager, create_farmq_database_manager,
FarmqBase
)
# 하위 호환성을 위한 별칭
MachineSpecs = MachineProfile # 기존 이름과의 호환성
MonitoringData = MonitoringMetrics # 기존 이름과의 호환성
__all__ = [
# Headscale 모델
'User', 'Node', 'PreAuthKey', 'ApiKey', 'Policy',
'Base', 'create_all_tables',
# FARMQ 모델
'PharmacyInfo', 'MachineProfile', 'MonitoringMetrics', 'SystemAlert',
'FarmqDatabaseManager', 'create_farmq_database_manager', 'FarmqBase',
# 하위 호환성 별칭
'MachineSpecs', 'MonitoringData'
]

View File

@@ -0,0 +1,510 @@
"""
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}")

View File

@@ -0,0 +1,385 @@
"""
Headscale SQLite Database Models for SQLAlchemy
Based on actual schema analysis of Headscale v0.23.0
Generated from: /var/lib/headscale/db.sqlite
Schema Analysis Date: 2025-09-09
"""
from datetime import datetime, timedelta
from typing import Optional, List
import json
from sqlalchemy import (
Column, Integer, String, DateTime, Boolean, Text,
ForeignKey, LargeBinary, Index, UniqueConstraint
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, Session
from sqlalchemy.types import TypeDecorator, TEXT
Base = declarative_base()
class JSONType(TypeDecorator):
"""Custom JSON type for SQLAlchemy that handles JSON serialization"""
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 Migration(Base):
"""Migration tracking table"""
__tablename__ = 'migrations'
id = Column(String, primary_key=True)
def __repr__(self):
return f"<Migration(id='{self.id}')>"
class User(Base):
"""Headscale Users table
Represents individual users/namespaces in the Headscale network.
Each user can have multiple nodes (machines) associated with them.
"""
__tablename__ = 'users'
__table_args__ = (
Index('idx_users_deleted_at', 'deleted_at'),
Index('idx_provider_identifier', 'provider_identifier',
postgresql_where="provider_identifier IS NOT NULL"),
Index('idx_name_provider_identifier', 'name', 'provider_identifier'),
Index('idx_name_no_provider_identifier', 'name',
postgresql_where="provider_identifier IS NULL"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
created_at = Column(DateTime)
updated_at = Column(DateTime)
deleted_at = Column(DateTime) # Soft delete
name = Column(String) # User identifier (e.g., "myuser")
display_name = Column(String) # Human-readable display name
email = Column(String) # User email address
provider_identifier = Column(String) # External auth provider ID
provider = Column(String) # Auth provider name (OIDC, etc.)
profile_pic_url = Column(String) # Profile picture URL
# Relationships
nodes = relationship("Node", back_populates="user", cascade="all, delete-orphan")
pre_auth_keys = relationship("PreAuthKey", back_populates="user")
def __repr__(self):
return f"<User(id={self.id}, name='{self.name}', display_name='{self.display_name}')>"
def is_deleted(self) -> bool:
"""Check if user is soft-deleted"""
return self.deleted_at is not None
class Node(Base):
"""Headscale Nodes (Machines) table
Represents individual devices/machines connected to the Tailnet.
Each node belongs to a user and has various networking attributes.
"""
__tablename__ = 'nodes'
id = Column(Integer, primary_key=True, autoincrement=True)
machine_key = Column(String) # Machine's public key
node_key = Column(String) # Node's network key
disco_key = Column(String) # Discovery key for peer-to-peer connections
endpoints = Column(JSONType) # List of network endpoints (JSON array)
host_info = Column(JSONType) # Detailed host information (JSON object)
ipv4 = Column(String) # Assigned IPv4 address (e.g., "100.64.0.1")
ipv6 = Column(String) # Assigned IPv6 address
hostname = Column(String) # Machine hostname
given_name = Column(String) # User-assigned machine name
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
register_method = Column(String) # Registration method (e.g., "authkey")
forced_tags = Column(JSONType) # Tags forced on this node (JSON array)
auth_key_id = Column(Integer, ForeignKey('pre_auth_keys.id'))
expiry = Column(DateTime) # Node expiration date
last_seen = Column(DateTime) # Last activity timestamp
approved_routes = Column(JSONType) # Approved subnet routes (JSON array)
created_at = Column(DateTime)
updated_at = Column(DateTime)
deleted_at = Column(DateTime) # Soft delete
# Relationships
user = relationship("User", back_populates="nodes")
auth_key = relationship("PreAuthKey")
def __repr__(self):
return f"<Node(id={self.id}, hostname='{self.hostname}', ipv4='{self.ipv4}', user_id={self.user_id})>"
def is_online(self, timeout_minutes: int = 1440) -> bool: # 24 hours timeout like Tailscale
"""Check if node is considered online based on last_seen"""
if not self.last_seen:
return False
# Handle timezone-aware datetime properly
now = datetime.now()
last_seen = self.last_seen
# Convert both to naive datetime to avoid timezone issues
if last_seen.tzinfo is not None:
last_seen = last_seen.replace(tzinfo=None)
if now.tzinfo is not None:
now = now.replace(tzinfo=None)
try:
time_diff_seconds = (now - last_seen).total_seconds()
# Consider online if last seen within timeout_minutes
is_recent = time_diff_seconds < (timeout_minutes * 60)
return is_recent
except TypeError as e:
# Fallback: just check if we have a recent timestamp
return True
def get_host_info(self) -> dict:
"""Get parsed host information"""
return self.host_info or {}
def get_endpoints(self) -> List[str]:
"""Get list of network endpoints"""
return self.endpoints or []
def get_forced_tags(self) -> List[str]:
"""Get list of forced tags"""
return self.forced_tags or []
def get_approved_routes(self) -> List[str]:
"""Get list of approved routes"""
return self.approved_routes or []
class PreAuthKey(Base):
"""Pre-authentication keys table
Keys used for automatic node registration without manual approval.
"""
__tablename__ = 'pre_auth_keys'
id = Column(Integer, primary_key=True, autoincrement=True)
key = Column(String) # The actual pre-auth key string
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'))
reusable = Column(Boolean) # Can be used multiple times
ephemeral = Column(Boolean, default=False) # Temporary key
used = Column(Boolean, default=False) # Has been used
tags = Column(JSONType) # Tags to apply to nodes using this key
created_at = Column(DateTime)
expiration = Column(DateTime) # When the key expires
# Relationships
user = relationship("User", back_populates="pre_auth_keys")
def __repr__(self):
return f"<PreAuthKey(id={self.id}, key='{self.key[:8]}...', user_id={self.user_id}, reusable={self.reusable})>"
def is_expired(self) -> bool:
"""Check if the pre-auth key is expired"""
if not self.expiration:
return False
now = datetime.now()
expiration = self.expiration
# Handle timezone-aware datetime
if expiration.tzinfo is not None and now.tzinfo is None:
from datetime import timezone
now = now.replace(tzinfo=timezone.utc)
elif expiration.tzinfo is not None:
expiration = expiration.replace(tzinfo=None)
try:
return now > expiration
except TypeError:
return False
def is_valid(self) -> bool:
"""Check if the key is still valid for use"""
if self.is_expired():
return False
if self.used and not self.reusable:
return False
return True
def get_tags(self) -> List[str]:
"""Get list of tags for this key"""
return self.tags or []
class ApiKey(Base):
"""API Keys table
Keys used for API authentication to the Headscale server.
"""
__tablename__ = 'api_keys'
__table_args__ = (
Index('idx_api_keys_prefix', 'prefix', unique=True),
)
id = Column(Integer, primary_key=True, autoincrement=True)
prefix = Column(String) # Key prefix for identification (e.g., "8qRr1IB")
hash = Column(LargeBinary) # Hashed key value
created_at = Column(DateTime)
expiration = Column(DateTime) # When the key expires
last_seen = Column(DateTime) # Last time key was used
def __repr__(self):
return f"<ApiKey(id={self.id}, prefix='{self.prefix}', created_at='{self.created_at}')>"
def is_expired(self) -> bool:
"""Check if the API key is expired"""
if not self.expiration:
return False
now = datetime.now()
expiration = self.expiration
# Handle timezone-aware datetime
if expiration.tzinfo is not None and now.tzinfo is None:
from datetime import timezone
now = now.replace(tzinfo=timezone.utc)
elif expiration.tzinfo is not None:
expiration = expiration.replace(tzinfo=None)
try:
return now > expiration
except TypeError:
return False
class Policy(Base):
"""ACL Policies table
Stores Access Control List policies in JSON format.
"""
__tablename__ = 'policies'
__table_args__ = (
Index('idx_policies_deleted_at', 'deleted_at'),
)
id = Column(Integer, primary_key=True, autoincrement=True)
created_at = Column(DateTime)
updated_at = Column(DateTime)
deleted_at = Column(DateTime) # Soft delete
data = Column(Text) # JSON policy data
def __repr__(self):
return f"<Policy(id={self.id}, created_at='{self.created_at}')>"
def get_policy_data(self) -> dict:
"""Parse and return policy data as dictionary"""
try:
return json.loads(self.data) if self.data else {}
except json.JSONDecodeError:
return {}
# ==========================================
# Extended Models for FARMQ Customization
# ==========================================
# ==========================================
# Extended Models for FARMQ Customization - DEPRECATED
# ==========================================
#
# 주의: 이 모델들은 Headscale과 외래키 충돌을 일으키므로 더 이상 사용하지 않습니다.
# 대신 farmq_models.py의 독립적인 모델들을 사용하세요.
#
# 마이그레이션 가이드:
# - PharmacyInfo -> farmq_models.PharmacyInfo
# - MachineSpecs -> farmq_models.MachineProfile
# - MonitoringData -> farmq_models.MonitoringMetrics
#
# 이 클래스들은 하위 호환성을 위해 유지되지만 실제 테이블은 생성되지 않습니다.
# ==========================================
# Database Helper Functions
# ==========================================
def create_all_tables(engine):
"""Create all tables in the database"""
Base.metadata.create_all(engine)
def get_active_nodes(session: Session) -> List[Node]:
"""Get all non-deleted nodes"""
return session.query(Node).filter(Node.deleted_at.is_(None)).all()
def get_online_nodes(session: Session, timeout_minutes: int = 5) -> List[Node]:
"""Get nodes that are currently online"""
cutoff_time = datetime.now() - timedelta(minutes=timeout_minutes)
return session.query(Node).filter(
Node.deleted_at.is_(None),
Node.last_seen > cutoff_time
).all()
def get_user_with_pharmacy_info(session: Session, user_name: str):
"""Get user with associated pharmacy information"""
return session.query(User).join(PharmacyInfo).filter(User.name == user_name).first()
def get_node_with_specs_and_monitoring(session: Session, node_id: int):
"""Get node with hardware specs and latest monitoring data"""
return session.query(Node)\
.outerjoin(MachineSpecs)\
.outerjoin(MonitoringData)\
.filter(Node.id == node_id)\
.first()
# ==========================================
# Usage Example
# ==========================================
if __name__ == "__main__":
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# SQLite connection to Headscale database
DATABASE_URL = "sqlite:///data/db.sqlite"
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create extended tables (if needed)
create_all_tables(engine)
# Example usage
session = SessionLocal()
try:
# Get all users
users = session.query(User).all()
print("=== Users ===")
for user in users:
print(f" {user}")
# Get all nodes
nodes = session.query(Node).all()
print("\n=== Nodes ===")
for node in nodes:
print(f" {node}")
print(f" Online: {node.is_online()}")
print(f" Host Info: {node.get_host_info().get('Hostname', 'Unknown')}")
# Get all API keys
api_keys = session.query(ApiKey).all()
print("\n=== API Keys ===")
for key in api_keys:
print(f" {key}")
print(f" Expired: {key.is_expired()}")
finally:
session.close()