머신 상세 페이지 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')
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:
print(f"❌ Error in machine_detail route: {e}")
import traceback

View File

@ -96,7 +96,17 @@
</tr>
<tr>
<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>
</table>
</div>
@ -141,7 +151,11 @@
<th>마지막 접속</th>
<td>
{% if machine.last_seen %}
{{ machine.last_seen.strftime('%Y-%m-%d %H:%M:%S') }}
{% if machine.last_seen.__class__.__name__ == 'datetime' %}
{{ machine.last_seen.strftime('%Y-%m-%d %H:%M:%S') }}
{% else %}
{{ machine.last_seen }}
{% endif %}
<br><small class="text-muted">{{ last_seen_humanized }}</small>
{% else %}
<span class="text-muted">알 수 없음</span>
@ -210,7 +224,16 @@
<div class="card">
<div class="card-header">
<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 class="card-body">
<div class="row">

View File

@ -323,54 +323,101 @@ def get_all_machines_with_details() -> List[Dict[str, Any]]:
close_session(headscale_session)
def get_machine_detail(machine_id: int) -> Optional[Dict[str, Any]]:
"""머신 상세 정보 조회"""
farmq_session = get_farmq_session()
"""Headscale CLI를 통한 머신 상세 정보 조회"""
import subprocess
import json
from datetime import datetime
try:
machine = farmq_session.query(MachineProfile).filter(
MachineProfile.id == machine_id
).first()
# 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)
# machine_id로 노드 찾기 (id 기준)
machine = None
for node in nodes_data:
if node.get('id') == machine_id:
machine = node
break
if not machine:
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:
pharmacy = farmq_session.query(PharmacyInfo).filter(
PharmacyInfo.id == machine.pharmacy_id
).first()
if pharmacy:
machine_data['pharmacy'] = pharmacy.to_dict()
# 시간 변환 함수
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
# 최근 모니터링 데이터 (24시간)
cutoff_time = datetime.now() - timedelta(hours=24)
metrics = farmq_session.query(MonitoringMetrics).filter(
MonitoringMetrics.machine_profile_id == machine_id,
MonitoringMetrics.collected_at > cutoff_time
).order_by(desc(MonitoringMetrics.collected_at)).limit(100).all()
# 엔드포인트 추출
endpoints = []
if 'endpoints' in machine:
endpoints = machine['endpoints']
machine_data['metrics_history'] = [metric.to_dict() for metric in metrics]
# 약국 정보 매핑 (사용자명을 통해)
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(
PharmacyInfo.headscale_user_name == user_name
).first()
if pharmacy:
pharmacy_info = {
'id': pharmacy.id,
'name': pharmacy.pharmacy_name,
'manager': pharmacy.manager_name,
'address': pharmacy.address
}
finally:
farmq_session.close()
# 최신 메트릭스
if metrics:
latest = metrics[0]
machine_data['latest_metrics'] = latest.to_dict()
machine_data['alerts'] = latest.get_alert_status()
# 활성 알림들
active_alerts = farmq_session.query(SystemAlert).filter(
SystemAlert.machine_profile_id == machine_id,
SystemAlert.status == 'active'
).order_by(desc(SystemAlert.created_at)).limit(10).all()
machine_data['active_alerts'] = [alert.to_dict() for alert in active_alerts]
# 반환 데이터 구성
machine_data = {
'id': machine.get('id'),
'given_name': machine.get('given_name'),
'hostname': machine.get('name'),
'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_key': machine.get('machine_key'),
'node_key': machine.get('node_key'),
'disco_key': machine.get('disco_key'),
'user': machine.get('user'),
'last_seen': convert_timestamp(machine.get('last_seen')),
'created_at': convert_timestamp(machine.get('created_at')),
'register_method': 'CLI' if machine.get('register_method') == 1 else 'Pre-auth Key',
'online': is_online,
'endpoints': endpoints,
'pharmacy': pharmacy_info,
# 헬퍼 메서드
'get_endpoints': lambda: endpoints,
}
return machine_data
finally:
close_session(farmq_session)
except subprocess.CalledProcessError as e:
print(f"❌ Headscale CLI 오류: {e}")
return None
except Exception as e:
print(f"❌ 머신 상세 정보 조회 오류: {e}")
return None
# ==========================================
# Headscale Synchronization