머신 상세 페이지 Headscale CLI 기반 완전 재구현

- get_machine_detail 함수를 Headscale CLI 기반으로 완전 교체
- 기존 FARMQ DB 의존성에서 실시간 Headscale 데이터로 전환
- strftime 템플릿 오류 완전 해결 (datetime 객체 타입 체크 추가)
- 실제 머신 정보 표시: 호스트명, IP 주소, 온라인 상태, 사용자 정보
- 약국 정보 매핑: Headscale 사용자명을 통한 약국 연동
- 시간 정보 인간화: "N시간 전", "N분 전" 형식으로 표시
- 네트워크 정보: IPv4/IPv6 주소, 엔드포인트, 키 정보 표시
- 조건부 모니터링 데이터 표시 (향후 확장 대비)
- 전체 머신 상세 페이지 기능 정상화

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
시골약사 2025-09-11 12:07:53 +09:00
parent 56b72629f9
commit f3965a67fd
3 changed files with 136 additions and 40 deletions

View File

@ -117,7 +117,33 @@ def create_app(config_name=None):
hostname = details.get('hostname', 'Unknown') hostname = details.get('hostname', 'Unknown')
print(f"✅ Rendering detail page for machine: {hostname}") print(f"✅ Rendering detail page for machine: {hostname}")
return render_template('machines/detail.html', machine=details) # 템플릿에 필요한 추가 변수들
is_online = details.get('online', False)
last_seen = details.get('last_seen')
# 시간 형식화
if last_seen:
from datetime import datetime, timezone
if isinstance(last_seen, datetime):
now = datetime.now(timezone.utc) if last_seen.tzinfo else datetime.now()
delta = now - last_seen
if delta.days > 0:
last_seen_humanized = f"{delta.days}일 전"
elif delta.seconds > 3600:
last_seen_humanized = f"{delta.seconds // 3600}시간 전"
elif delta.seconds > 60:
last_seen_humanized = f"{delta.seconds // 60}분 전"
else:
last_seen_humanized = "방금 전"
else:
last_seen_humanized = "알 수 없음"
else:
last_seen_humanized = "알 수 없음"
return render_template('machines/detail.html',
machine=details,
is_online=is_online,
last_seen_humanized=last_seen_humanized)
except Exception as e: except Exception as e:
print(f"❌ Error in machine_detail route: {e}") print(f"❌ Error in machine_detail route: {e}")
import traceback import traceback

View File

@ -96,7 +96,17 @@
</tr> </tr>
<tr> <tr>
<th>등록일</th> <th>등록일</th>
<td>{{ machine.created_at.strftime('%Y년 %m월 %d일 %H:%M') if machine.created_at else '알 수 없음' }}</td> <td>
{% if machine.created_at %}
{% if machine.created_at.__class__.__name__ == 'datetime' %}
{{ machine.created_at.strftime('%Y년 %m월 %d일 %H:%M') }}
{% else %}
{{ machine.created_at }}
{% endif %}
{% else %}
알 수 없음
{% endif %}
</td>
</tr> </tr>
</table> </table>
</div> </div>
@ -141,7 +151,11 @@
<th>마지막 접속</th> <th>마지막 접속</th>
<td> <td>
{% if machine.last_seen %} {% if machine.last_seen %}
{% if machine.last_seen.__class__.__name__ == 'datetime' %}
{{ machine.last_seen.strftime('%Y-%m-%d %H:%M:%S') }} {{ machine.last_seen.strftime('%Y-%m-%d %H:%M:%S') }}
{% else %}
{{ machine.last_seen }}
{% endif %}
<br><small class="text-muted">{{ last_seen_humanized }}</small> <br><small class="text-muted">{{ last_seen_humanized }}</small>
{% else %} {% else %}
<span class="text-muted">알 수 없음</span> <span class="text-muted">알 수 없음</span>
@ -210,7 +224,16 @@
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0"><i class="fas fa-chart-line"></i> 현재 상태</h5> <h5 class="mb-0"><i class="fas fa-chart-line"></i> 현재 상태</h5>
<small class="text-muted">최종 업데이트: {{ latest_monitoring.collected_at.strftime('%Y-%m-%d %H:%M:%S') }}</small> {% if latest_monitoring and latest_monitoring.collected_at %}
<small class="text-muted">
최종 업데이트:
{% if latest_monitoring.collected_at.__class__.__name__ == 'datetime' %}
{{ latest_monitoring.collected_at.strftime('%Y-%m-%d %H:%M:%S') }}
{% else %}
{{ latest_monitoring.collected_at }}
{% endif %}
</small>
{% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">

View File

@ -323,54 +323,101 @@ def get_all_machines_with_details() -> List[Dict[str, Any]]:
close_session(headscale_session) close_session(headscale_session)
def get_machine_detail(machine_id: int) -> Optional[Dict[str, Any]]: def get_machine_detail(machine_id: int) -> Optional[Dict[str, Any]]:
"""머신 상세 정보 조회""" """Headscale CLI를 통한 머신 상세 정보 조회"""
farmq_session = get_farmq_session() import subprocess
import json
from datetime import datetime
try: try:
machine = farmq_session.query(MachineProfile).filter( # Headscale CLI에서 노드 목록 가져오기
MachineProfile.id == machine_id result = subprocess.run(
).first() ['docker', 'exec', 'headscale', 'headscale', 'nodes', 'list', '-o', 'json'],
capture_output=True,
text=True,
check=True
)
nodes_data = json.loads(result.stdout)
# machine_id로 노드 찾기 (id 기준)
machine = None
for node in nodes_data:
if node.get('id') == machine_id:
machine = node
break
if not machine: if not machine:
return None return None
machine_data = machine.to_dict() # 온라인 상태 체크
online_status = get_headscale_online_status()
node_name = machine.get('given_name') or machine.get('name', '')
is_online = online_status.get(node_name.lower(), False)
# 약국 정보 # 시간 변환 함수
if machine.pharmacy_id: def convert_timestamp(ts_obj):
if isinstance(ts_obj, dict) and 'seconds' in ts_obj:
return datetime.fromtimestamp(ts_obj['seconds'])
elif isinstance(ts_obj, str):
try:
return datetime.fromisoformat(ts_obj.replace('Z', '+00:00'))
except:
return None
return None
# 엔드포인트 추출
endpoints = []
if 'endpoints' in machine:
endpoints = machine['endpoints']
# 약국 정보 매핑 (사용자명을 통해)
pharmacy_info = None
user_name = machine.get('user', {}).get('name', '')
if user_name:
farmq_session = get_farmq_session()
try:
pharmacy = farmq_session.query(PharmacyInfo).filter( pharmacy = farmq_session.query(PharmacyInfo).filter(
PharmacyInfo.id == machine.pharmacy_id PharmacyInfo.headscale_user_name == user_name
).first() ).first()
if pharmacy: if pharmacy:
machine_data['pharmacy'] = pharmacy.to_dict() pharmacy_info = {
'id': pharmacy.id,
'name': pharmacy.pharmacy_name,
'manager': pharmacy.manager_name,
'address': pharmacy.address
}
finally:
farmq_session.close()
# 최근 모니터링 데이터 (24시간) # 반환 데이터 구성
cutoff_time = datetime.now() - timedelta(hours=24) machine_data = {
metrics = farmq_session.query(MonitoringMetrics).filter( 'id': machine.get('id'),
MonitoringMetrics.machine_profile_id == machine_id, 'given_name': machine.get('given_name'),
MonitoringMetrics.collected_at > cutoff_time 'hostname': machine.get('name'),
).order_by(desc(MonitoringMetrics.collected_at)).limit(100).all() 'ipv4': machine.get('ip_addresses', [])[0] if machine.get('ip_addresses') else None,
'ipv6': machine.get('ip_addresses', [])[1] if len(machine.get('ip_addresses', [])) > 1 else None,
machine_data['metrics_history'] = [metric.to_dict() for metric in metrics] 'machine_key': machine.get('machine_key'),
'node_key': machine.get('node_key'),
# 최신 메트릭스 'disco_key': machine.get('disco_key'),
if metrics: 'user': machine.get('user'),
latest = metrics[0] 'last_seen': convert_timestamp(machine.get('last_seen')),
machine_data['latest_metrics'] = latest.to_dict() 'created_at': convert_timestamp(machine.get('created_at')),
machine_data['alerts'] = latest.get_alert_status() 'register_method': 'CLI' if machine.get('register_method') == 1 else 'Pre-auth Key',
'online': is_online,
# 활성 알림들 'endpoints': endpoints,
active_alerts = farmq_session.query(SystemAlert).filter( 'pharmacy': pharmacy_info,
SystemAlert.machine_profile_id == machine_id, # 헬퍼 메서드
SystemAlert.status == 'active' 'get_endpoints': lambda: endpoints,
).order_by(desc(SystemAlert.created_at)).limit(10).all() }
machine_data['active_alerts'] = [alert.to_dict() for alert in active_alerts]
return machine_data return machine_data
finally: except subprocess.CalledProcessError as e:
close_session(farmq_session) print(f"❌ Headscale CLI 오류: {e}")
return None
except Exception as e:
print(f"❌ 머신 상세 정보 조회 오류: {e}")
return None
# ========================================== # ==========================================
# Headscale Synchronization # Headscale Synchronization