Implement real-time online status synchronization with Headplane

- Add Headscale CLI integration to get real-time online status
- Replace timeout-based logic with exact same logic as Headplane
- Use 'online' field from Headscale CLI JSON output
- Update dashboard statistics to show 3 online nodes matching Headplane
- Update pharmacy and machine management views with real-time status

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
시골약사 2025-09-11 10:38:14 +09:00
parent 1f0afd4cae
commit 11f6ff16d0
2 changed files with 91 additions and 39 deletions

View File

@ -39,7 +39,7 @@
<div class="col-lg-3 col-md-6 mb-3"> <div class="col-lg-3 col-md-6 mb-3">
<div class="card" style="background: linear-gradient(135deg, #48bb78 0%, #38a169 100%); color: white;"> <div class="card" style="background: linear-gradient(135deg, #48bb78 0%, #38a169 100%); color: white;">
<div class="card-body text-center"> <div class="card-body text-center">
<div class="stat-number" id="online-machines">{{ stats.online_machines }}</div> <div class="stat-number" id="online-machines" data-stat="online">{{ stats.online_machines }}</div>
<div class="stat-label"> <div class="stat-label">
<i class="fas fa-circle text-success"></i> 온라인 머신 <i class="fas fa-circle text-success"></i> 온라인 머신
</div> </div>
@ -50,7 +50,7 @@
<div class="col-lg-3 col-md-6 mb-3"> <div class="col-lg-3 col-md-6 mb-3">
<div class="card" style="background: linear-gradient(135deg, #f56565 0%, #e53e3e 100%); color: white;"> <div class="card" style="background: linear-gradient(135deg, #f56565 0%, #e53e3e 100%); color: white;">
<div class="card-body text-center"> <div class="card-body text-center">
<div class="stat-number" id="offline-machines">{{ stats.offline_machines }}</div> <div class="stat-number" id="offline-machines" data-stat="offline">{{ stats.offline_machines }}</div>
<div class="stat-label"> <div class="stat-label">
<i class="fas fa-circle text-danger"></i> 오프라인 머신 <i class="fas fa-circle text-danger"></i> 오프라인 머신
</div> </div>
@ -250,6 +250,26 @@ document.addEventListener('DOMContentLoaded', function() {
createDoughnutChart('diskChart', 60, '디스크', '#f59e0b'); createDoughnutChart('diskChart', 60, '디스크', '#f59e0b');
}); });
// 실시간 통계 업데이트
function updateStats() {
fetch('/api/dashboard/stats')
.then(response => response.json())
.then(stats => {
// 머신 상태 업데이트
const onlineElement = document.querySelector('[data-stat="online"]');
const offlineElement = document.querySelector('[data-stat="offline"]');
const totalElement = document.querySelector('[data-stat="total"]');
if (onlineElement) onlineElement.textContent = stats.online_machines;
if (offlineElement) offlineElement.textContent = stats.offline_machines;
if (totalElement) totalElement.textContent = stats.total_machines;
// CPU 온도 차트 업데이트
updateChartValue('cpuChart', stats.avg_cpu_temp);
})
.catch(error => console.error('Stats update failed:', error));
}
// 실시간 알림 업데이트 // 실시간 알림 업데이트
function updateAlerts() { function updateAlerts() {
fetch('/api/alerts') fetch('/api/alerts')
@ -271,6 +291,8 @@ function updateAlerts() {
.catch(error => console.error('Alert update failed:', error)); .catch(error => console.error('Alert update failed:', error));
} }
// 통계 업데이트 (10초마다 - 더 자주)
setInterval(updateStats, 10000);
// 알림 업데이트 (30초마다) // 알림 업데이트 (30초마다)
setInterval(updateAlerts, 30000); setInterval(updateAlerts, 30000);
</script> </script>

View File

@ -4,6 +4,8 @@
""" """
import os import os
import json
import subprocess
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta from datetime import datetime, timedelta
from sqlalchemy import create_engine, text, and_, or_, desc from sqlalchemy import create_engine, text, and_, or_, desc
@ -53,12 +55,43 @@ def close_session(session: Session):
if session: if session:
session.close() 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 # Dashboard Statistics
# ========================================== # ==========================================
def get_dashboard_stats() -> Dict[str, Any]: def get_dashboard_stats() -> Dict[str, Any]:
"""대시보드 통계 조회""" """대시보드 통계 조회 - 실시간 DB 쿼리"""
farmq_session = get_farmq_session() farmq_session = get_farmq_session()
headscale_session = get_headscale_session() headscale_session = get_headscale_session()
@ -68,19 +101,31 @@ def get_dashboard_stats() -> Dict[str, Any]:
PharmacyInfo.status == 'active' PharmacyInfo.status == 'active'
).count() ).count()
# 머신 상태 - Headscale 노드 기준으로 계산 # 머신 상태 - Headscale 노드에서 직접 실시간 조회
from models.headscale_models import Node from models.headscale_models import Node
# 전체 머신 수 (Headscale 노드 기준) # 활성 노드만 조회 (deleted_at이 null인 것)
total_machines = headscale_session.query(Node).count() active_nodes = headscale_session.query(Node).filter(
Node.deleted_at.is_(None)
).all()
# 온라인 머신 수 (last_seen이 최근 5분 이내) total_machines = len(active_nodes)
cutoff_time = datetime.now() - timedelta(minutes=5)
online_machines = headscale_session.query(Node).filter( # Headscale CLI를 통해 실시간 온라인 상태 가져오기
Node.last_seen > cutoff_time online_status = get_headscale_online_status()
).count()
# 온라인 머신 수 계산
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 offline_machines = total_machines - online_machines
print(f"📊 최종 카운트: {online_machines}/{total_machines} 온라인 (Headplane과 동일)")
# 최근 알림 수 # 최근 알림 수
recent_alerts = farmq_session.query(SystemAlert).filter( recent_alerts = farmq_session.query(SystemAlert).filter(
@ -129,6 +174,9 @@ def get_all_pharmacies_with_stats() -> List[Dict[str, Any]]:
PharmacyInfo.status == 'active' PharmacyInfo.status == 'active'
).all() ).all()
# Headscale CLI를 통해 실시간 온라인 상태 가져오기
online_status = get_headscale_online_status()
result = [] result = []
for pharmacy in pharmacies: for pharmacy in pharmacies:
# Headscale에서 해당 사용자의 머신 수 조회 # Headscale에서 해당 사용자의 머신 수 조회
@ -139,20 +187,12 @@ def get_all_pharmacies_with_stats() -> List[Dict[str, Any]]:
machine_count = len(user_machines) machine_count = len(user_machines)
# 온라인 머신 수 계산 (24시간 timeout) # 온라인 머신 수 계산 - Headscale CLI 기반
online_count = 0 online_count = 0
for machine in user_machines: for machine in user_machines:
if machine.last_seen: node_name = (machine.given_name or machine.hostname or '').lower()
try: if online_status.get(node_name, False):
from datetime import timezone online_count += 1
if machine.last_seen.tzinfo is not None:
cutoff_time = datetime.now(timezone.utc) - timedelta(hours=24)
else:
cutoff_time = datetime.now() - timedelta(hours=24)
if machine.last_seen > cutoff_time:
online_count += 1
except Exception:
online_count += 1 # 타임존 에러 시 온라인으로 간주
# 활성 알림 수 (현재는 0으로 설정, 나중에 구현) # 활성 알림 수 (현재는 0으로 설정, 나중에 구현)
alert_count = 0 alert_count = 0
@ -230,6 +270,9 @@ def get_all_machines_with_details() -> List[Dict[str, Any]]:
print(f"🔍 Found {len(nodes)} nodes in Headscale database") print(f"🔍 Found {len(nodes)} nodes in Headscale database")
# Headscale CLI를 통해 실시간 온라인 상태 가져오기
online_status = get_headscale_online_status()
result = [] result = []
for node in nodes: for node in nodes:
# 기본 머신 정보 # 기본 머신 정보
@ -246,22 +289,9 @@ def get_all_machines_with_details() -> List[Dict[str, Any]]:
'updated_at': node.updated_at 'updated_at': node.updated_at
} }
# 온라인 상태 확인 (24시간 timeout) # Headscale CLI 기반 온라인 상태 확인
if node.last_seen: node_name = (node.given_name or node.hostname or '').lower()
try: machine_data['is_online'] = online_status.get(node_name, False)
from datetime import timezone
# node.last_seen이 timezone-aware인지 확인
if node.last_seen.tzinfo is not None:
cutoff_time = datetime.now(timezone.utc) - timedelta(hours=24)
else:
cutoff_time = datetime.now() - timedelta(hours=24)
machine_data['is_online'] = node.last_seen > cutoff_time
except Exception as e:
# 타임존 비교 에러가 발생하면 기본적으로 온라인으로 가정
print(f"Timezone comparison error for {node.hostname}: {e}")
machine_data['is_online'] = True
else:
machine_data['is_online'] = False
# 사용자 이름으로 약국 정보 찾기 # 사용자 이름으로 약국 정보 찾기
machine_data['pharmacy'] = None machine_data['pharmacy'] = None