From 11f6ff16d01d395ec7eec6e0a3ca32a4e099bfc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=9C=EA=B3=A8=EC=95=BD=EC=82=AC?= Date: Thu, 11 Sep 2025 10:38:14 +0900 Subject: [PATCH] Implement real-time online status synchronization with Headplane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- farmq-admin/templates/dashboard/index.html | 26 +++++- farmq-admin/utils/database_new.py | 104 +++++++++++++-------- 2 files changed, 91 insertions(+), 39 deletions(-) diff --git a/farmq-admin/templates/dashboard/index.html b/farmq-admin/templates/dashboard/index.html index 0ba500c..14c1ce1 100644 --- a/farmq-admin/templates/dashboard/index.html +++ b/farmq-admin/templates/dashboard/index.html @@ -39,7 +39,7 @@
-
{{ stats.online_machines }}
+
{{ stats.online_machines }}
์˜จ๋ผ์ธ ๋จธ์‹ 
@@ -50,7 +50,7 @@
-
{{ stats.offline_machines }}
+
{{ stats.offline_machines }}
์˜คํ”„๋ผ์ธ ๋จธ์‹ 
@@ -250,6 +250,26 @@ document.addEventListener('DOMContentLoaded', function() { 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() { fetch('/api/alerts') @@ -271,6 +291,8 @@ function updateAlerts() { .catch(error => console.error('Alert update failed:', error)); } +// ํ†ต๊ณ„ ์—…๋ฐ์ดํŠธ (10์ดˆ๋งˆ๋‹ค - ๋” ์ž์ฃผ) +setInterval(updateStats, 10000); // ์•Œ๋ฆผ ์—…๋ฐ์ดํŠธ (30์ดˆ๋งˆ๋‹ค) setInterval(updateAlerts, 30000); diff --git a/farmq-admin/utils/database_new.py b/farmq-admin/utils/database_new.py index 4bc3306..7a705a0 100644 --- a/farmq-admin/utils/database_new.py +++ b/farmq-admin/utils/database_new.py @@ -4,6 +4,8 @@ """ import os +import json +import subprocess from typing import Optional, List, Dict, Any from datetime import datetime, timedelta from sqlalchemy import create_engine, text, and_, or_, desc @@ -53,12 +55,43 @@ def close_session(session: Session): if session: 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 # ========================================== def get_dashboard_stats() -> Dict[str, Any]: - """๋Œ€์‹œ๋ณด๋“œ ํ†ต๊ณ„ ์กฐํšŒ""" + """๋Œ€์‹œ๋ณด๋“œ ํ†ต๊ณ„ ์กฐํšŒ - ์‹ค์‹œ๊ฐ„ DB ์ฟผ๋ฆฌ""" farmq_session = get_farmq_session() headscale_session = get_headscale_session() @@ -68,19 +101,31 @@ def get_dashboard_stats() -> Dict[str, Any]: PharmacyInfo.status == 'active' ).count() - # ๋จธ์‹  ์ƒํƒœ - Headscale ๋…ธ๋“œ ๊ธฐ์ค€์œผ๋กœ ๊ณ„์‚ฐ + # ๋จธ์‹  ์ƒํƒœ - Headscale ๋…ธ๋“œ์—์„œ ์ง์ ‘ ์‹ค์‹œ๊ฐ„ ์กฐํšŒ from models.headscale_models import Node - # ์ „์ฒด ๋จธ์‹  ์ˆ˜ (Headscale ๋…ธ๋“œ ๊ธฐ์ค€) - total_machines = headscale_session.query(Node).count() + # ํ™œ์„ฑ ๋…ธ๋“œ๋งŒ ์กฐํšŒ (deleted_at์ด null์ธ ๊ฒƒ) + active_nodes = headscale_session.query(Node).filter( + Node.deleted_at.is_(None) + ).all() - # ์˜จ๋ผ์ธ ๋จธ์‹  ์ˆ˜ (last_seen์ด ์ตœ๊ทผ 5๋ถ„ ์ด๋‚ด) - cutoff_time = datetime.now() - timedelta(minutes=5) - online_machines = headscale_session.query(Node).filter( - Node.last_seen > cutoff_time - ).count() + total_machines = len(active_nodes) + + # Headscale CLI๋ฅผ ํ†ตํ•ด ์‹ค์‹œ๊ฐ„ ์˜จ๋ผ์ธ ์ƒํƒœ ๊ฐ€์ ธ์˜ค๊ธฐ + online_status = get_headscale_online_status() + + # ์˜จ๋ผ์ธ ๋จธ์‹  ์ˆ˜ ๊ณ„์‚ฐ + 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 + print(f"๐Ÿ“Š ์ตœ์ข… ์นด์šดํŠธ: {online_machines}/{total_machines} ์˜จ๋ผ์ธ (Headplane๊ณผ ๋™์ผ)") # ์ตœ๊ทผ ์•Œ๋ฆผ ์ˆ˜ recent_alerts = farmq_session.query(SystemAlert).filter( @@ -129,6 +174,9 @@ def get_all_pharmacies_with_stats() -> List[Dict[str, Any]]: PharmacyInfo.status == 'active' ).all() + # Headscale CLI๋ฅผ ํ†ตํ•ด ์‹ค์‹œ๊ฐ„ ์˜จ๋ผ์ธ ์ƒํƒœ ๊ฐ€์ ธ์˜ค๊ธฐ + online_status = get_headscale_online_status() + result = [] for pharmacy in pharmacies: # Headscale์—์„œ ํ•ด๋‹น ์‚ฌ์šฉ์ž์˜ ๋จธ์‹  ์ˆ˜ ์กฐํšŒ @@ -139,20 +187,12 @@ def get_all_pharmacies_with_stats() -> List[Dict[str, Any]]: machine_count = len(user_machines) - # ์˜จ๋ผ์ธ ๋จธ์‹  ์ˆ˜ ๊ณ„์‚ฐ (24์‹œ๊ฐ„ timeout) + # ์˜จ๋ผ์ธ ๋จธ์‹  ์ˆ˜ ๊ณ„์‚ฐ - Headscale CLI ๊ธฐ๋ฐ˜ online_count = 0 for machine in user_machines: - if machine.last_seen: - try: - from datetime import timezone - 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 # ํƒ€์ž„์กด ์—๋Ÿฌ ์‹œ ์˜จ๋ผ์ธ์œผ๋กœ ๊ฐ„์ฃผ + node_name = (machine.given_name or machine.hostname or '').lower() + if online_status.get(node_name, False): + online_count += 1 # ํ™œ์„ฑ ์•Œ๋ฆผ ์ˆ˜ (ํ˜„์žฌ๋Š” 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") + # Headscale CLI๋ฅผ ํ†ตํ•ด ์‹ค์‹œ๊ฐ„ ์˜จ๋ผ์ธ ์ƒํƒœ ๊ฐ€์ ธ์˜ค๊ธฐ + online_status = get_headscale_online_status() + result = [] for node in nodes: # ๊ธฐ๋ณธ ๋จธ์‹  ์ •๋ณด @@ -246,22 +289,9 @@ def get_all_machines_with_details() -> List[Dict[str, Any]]: 'updated_at': node.updated_at } - # ์˜จ๋ผ์ธ ์ƒํƒœ ํ™•์ธ (24์‹œ๊ฐ„ timeout) - if node.last_seen: - try: - 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 + # Headscale CLI ๊ธฐ๋ฐ˜ ์˜จ๋ผ์ธ ์ƒํƒœ ํ™•์ธ + node_name = (node.given_name or node.hostname or '').lower() + machine_data['is_online'] = online_status.get(node_name, False) # ์‚ฌ์šฉ์ž ์ด๋ฆ„์œผ๋กœ ์•ฝ๊ตญ ์ •๋ณด ์ฐพ๊ธฐ machine_data['pharmacy'] = None