약국 상세 페이지 Headscale CLI 기반 완전 구현
- get_pharmacy_detail 함수를 Headscale CLI 기반으로 완전 재작성 - 기존 FARMQ DB의 MachineProfile 의존성 제거 - 약국 상세 페이지 템플릿 신규 생성 (detail.html) - 실시간 머신 상태 및 통계 표시: "머신: 2/4 온라인" - 사용자-약국 매핑을 통한 머신 연결 관리 - 연결된 머신 목록: IP, 상태, 등록방식, 마지막 접속시간 - datetime 객체 안전 처리로 strftime 오류 방지 - 머신별 상세보기/재연결/연결해제 액션 버튼 - 빈 상태 처리 및 사용자 가이드 제공 - 약국 기본정보: 사업자번호, 담당자, 연락처, 주소 - 네트워크 정보: Proxmox 호스트, Headscale 사용자 연결 - 상태별 아이콘 및 배지 시각화 (온라인/부분연결/오프라인) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f3965a67fd
commit
8dbf35d955
319
farmq-admin/templates/pharmacy/detail.html
Normal file
319
farmq-admin/templates/pharmacy/detail.html
Normal file
@ -0,0 +1,319 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}약국 상세 정보 - 팜큐 약국 관리 시스템{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('dashboard') }}">대시보드</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('pharmacy_list') }}">약국 관리</a></li>
|
||||
<li class="breadcrumb-item active">{{ pharmacy.pharmacy_name }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- 약국 정보 헤더 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-3">
|
||||
{% if pharmacy.online_machines > 0 %}
|
||||
<i class="fas fa-store fa-3x text-success"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-store fa-3x text-muted"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="h2 mb-0">{{ pharmacy.pharmacy_name }}</h1>
|
||||
<p class="text-muted mb-1">{{ pharmacy.manager_name or '담당자 미등록' }}</p>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
{% if pharmacy.online_machines == pharmacy.total_machines and pharmacy.total_machines > 0 %}
|
||||
<span class="badge bg-success">
|
||||
<i class="fas fa-check-circle"></i> 모든 머신 온라인
|
||||
</span>
|
||||
{% elif pharmacy.online_machines > 0 %}
|
||||
<span class="badge bg-warning">
|
||||
<i class="fas fa-exclamation-triangle"></i> 부분적 연결
|
||||
</span>
|
||||
{% elif pharmacy.total_machines > 0 %}
|
||||
<span class="badge bg-danger">
|
||||
<i class="fas fa-times-circle"></i> 전체 오프라인
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">
|
||||
<i class="fas fa-question-circle"></i> 머신 없음
|
||||
</span>
|
||||
{% endif %}
|
||||
<small class="text-muted">
|
||||
머신: {{ pharmacy.online_machines }}/{{ pharmacy.total_machines }} 온라인
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary" onclick="refreshPharmacyDetail()">
|
||||
<i class="fas fa-sync-alt"></i> 새로고침
|
||||
</button>
|
||||
<button class="btn btn-outline-primary">
|
||||
<i class="fas fa-edit"></i> 수정
|
||||
</button>
|
||||
<button class="btn btn-outline-info">
|
||||
<i class="fas fa-chart-line"></i> 모니터링
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 기본 정보 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-info-circle"></i> 기본 정보</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-borderless">
|
||||
<tr>
|
||||
<th width="30%">약국명</th>
|
||||
<td><strong>{{ pharmacy.pharmacy_name }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>사업자번호</th>
|
||||
<td>{{ pharmacy.business_number or '미등록' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>담당자</th>
|
||||
<td>{{ pharmacy.manager_name or '미등록' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>연락처</th>
|
||||
<td>{{ pharmacy.phone or '미등록' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>주소</th>
|
||||
<td>{{ pharmacy.address or '주소 미등록' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-network-wired"></i> 네트워크 정보</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-borderless">
|
||||
<tr>
|
||||
<th width="30%">Proxmox 호스트</th>
|
||||
<td>
|
||||
{% if pharmacy.proxmox_host %}
|
||||
<code>{{ pharmacy.proxmox_host }}</code>
|
||||
{% else %}
|
||||
<span class="text-muted">미설정</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Headscale 사용자</th>
|
||||
<td>
|
||||
{% if pharmacy.headscale_user_name %}
|
||||
<span class="badge bg-primary">{{ pharmacy.headscale_user_name }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">연결되지 않음</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>등록일</th>
|
||||
<td>
|
||||
{% if pharmacy.created_at %}
|
||||
{% if pharmacy.created_at.__class__.__name__ == 'datetime' %}
|
||||
{{ pharmacy.created_at.strftime('%Y년 %m월 %d일 %H:%M') }}
|
||||
{% else %}
|
||||
{{ pharmacy.created_at }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
알 수 없음
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>최종 업데이트</th>
|
||||
<td>
|
||||
{% if pharmacy.updated_at %}
|
||||
{% if pharmacy.updated_at.__class__.__name__ == 'datetime' %}
|
||||
{{ pharmacy.updated_at.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||
{% else %}
|
||||
{{ pharmacy.updated_at }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
알 수 없음
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 연결된 머신 목록 -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-desktop"></i>
|
||||
연결된 머신 목록
|
||||
<span class="badge bg-info ms-2">{{ pharmacy.total_machines }}대</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if machines %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>머신명</th>
|
||||
<th>IP 주소</th>
|
||||
<th>상태</th>
|
||||
<th>등록 방식</th>
|
||||
<th>마지막 접속</th>
|
||||
<th>액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for machine in machines %}
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
<strong>{{ machine.given_name or machine.hostname }}</strong>
|
||||
{% if machine.given_name and machine.hostname and machine.given_name != machine.hostname %}
|
||||
<br><small class="text-muted">{{ machine.hostname }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if machine.ipv4 %}
|
||||
<code>{{ machine.ipv4 }}</code>
|
||||
{% if machine.ipv6 %}
|
||||
<br><code class="small">{{ machine.ipv6 }}</code>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">IP 미할당</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if machine.online %}
|
||||
<span class="badge bg-success">
|
||||
<i class="fas fa-circle"></i> 온라인
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">
|
||||
<i class="fas fa-circle"></i> 오프라인
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary">{{ machine.register_method }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if machine.last_seen %}
|
||||
{% if machine.last_seen.__class__.__name__ == 'datetime' %}
|
||||
{{ machine.last_seen.strftime('%m/%d %H:%M') }}
|
||||
{% else %}
|
||||
{{ machine.last_seen }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">알 수 없음</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ url_for('machine_detail', machine_id=machine.id) }}"
|
||||
class="btn btn-outline-primary" title="상세 정보">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<button class="btn btn-outline-warning" title="재연결">
|
||||
<i class="fas fa-sync"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-danger"
|
||||
onclick="confirmDeleteMachine({{ machine.id }}, '{{ machine.given_name or machine.hostname }}')"
|
||||
title="연결 해제">
|
||||
<i class="fas fa-unlink"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="fas fa-desktop fa-4x mb-4 text-secondary"></i>
|
||||
<h4>연결된 머신이 없습니다</h4>
|
||||
<p class="mb-4">
|
||||
{% if pharmacy.headscale_user_name %}
|
||||
"{{ pharmacy.headscale_user_name }}" 사용자에 연결된 머신이 없습니다.
|
||||
{% else %}
|
||||
이 약국은 Headscale 사용자와 연결되지 않았습니다.<br>
|
||||
사용자 관리에서 사용자를 이 약국에 연결해주세요.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if not pharmacy.headscale_user_name %}
|
||||
<a href="{{ url_for('user_list') }}" class="btn btn-primary">
|
||||
<i class="fas fa-users"></i> 사용자 관리로 이동
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function refreshPharmacyDetail() {
|
||||
showToast('약국 정보를 새로고침 중...', 'info');
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function confirmDeleteMachine(machineId, machineName) {
|
||||
if (confirm(`정말로 머신 "${machineName}"의 연결을 해제하시겠습니까?`)) {
|
||||
deleteMachine(machineId, machineName);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteMachine(machineId, machineName) {
|
||||
fetch(`/api/nodes/${machineId}/delete`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(data.message, 'success');
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
showToast(data.error, 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('머신 삭제 오류:', error);
|
||||
showToast('머신 삭제 중 오류가 발생했습니다.', 'error');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -214,10 +214,15 @@ def get_all_pharmacies_with_stats() -> List[Dict[str, Any]]:
|
||||
close_session(headscale_session)
|
||||
|
||||
def get_pharmacy_detail(pharmacy_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""약국 상세 정보 조회"""
|
||||
"""Headscale CLI 기반 약국 상세 정보 조회"""
|
||||
import subprocess
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
farmq_session = get_farmq_session()
|
||||
|
||||
try:
|
||||
# 약국 기본 정보 조회
|
||||
pharmacy = farmq_session.query(PharmacyInfo).filter(
|
||||
PharmacyInfo.id == pharmacy_id
|
||||
).first()
|
||||
@ -225,33 +230,89 @@ def get_pharmacy_detail(pharmacy_id: int) -> Optional[Dict[str, Any]]:
|
||||
if not pharmacy:
|
||||
return None
|
||||
|
||||
# 약국의 머신들 조회
|
||||
machines = farmq_session.query(MachineProfile).filter(
|
||||
MachineProfile.pharmacy_id == pharmacy_id,
|
||||
MachineProfile.status == 'active'
|
||||
).all()
|
||||
# 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 = get_headscale_online_status()
|
||||
|
||||
# 약국과 연결된 사용자의 머신들 찾기
|
||||
machine_list = []
|
||||
for machine in machines:
|
||||
machine_data = machine.to_dict()
|
||||
|
||||
# 최근 모니터링 데이터
|
||||
latest_metrics = farmq_session.query(MonitoringMetrics).filter(
|
||||
MonitoringMetrics.machine_profile_id == machine.id
|
||||
).order_by(desc(MonitoringMetrics.collected_at)).first()
|
||||
|
||||
if latest_metrics:
|
||||
machine_data['latest_metrics'] = latest_metrics.to_dict()
|
||||
|
||||
machine_list.append(machine_data)
|
||||
if pharmacy.headscale_user_name:
|
||||
for node in nodes_data:
|
||||
# 이 노드가 약국의 사용자 것인지 확인
|
||||
node_user_name = node.get('user', {}).get('name', '')
|
||||
if node_user_name == pharmacy.headscale_user_name:
|
||||
# 시간 변환 함수
|
||||
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
|
||||
|
||||
node_name = node.get('given_name') or node.get('name', '')
|
||||
is_online = online_status.get(node_name.lower(), False)
|
||||
|
||||
machine_data = {
|
||||
'id': node.get('id'),
|
||||
'given_name': node.get('given_name'),
|
||||
'hostname': node.get('name'),
|
||||
'ipv4': node.get('ip_addresses', [])[0] if node.get('ip_addresses') else None,
|
||||
'ipv6': node.get('ip_addresses', [])[1] if len(node.get('ip_addresses', [])) > 1 else None,
|
||||
'machine_key': node.get('machine_key'),
|
||||
'node_key': node.get('node_key'),
|
||||
'disco_key': node.get('disco_key'),
|
||||
'user': node.get('user'),
|
||||
'last_seen': convert_timestamp(node.get('last_seen')),
|
||||
'created_at': convert_timestamp(node.get('created_at')),
|
||||
'register_method': 'CLI' if node.get('register_method') == 1 else 'Pre-auth Key',
|
||||
'online': is_online,
|
||||
'endpoints': node.get('endpoints', []),
|
||||
}
|
||||
|
||||
machine_list.append(machine_data)
|
||||
|
||||
# 약국 정보에 통계 추가
|
||||
pharmacy_data = {
|
||||
'id': pharmacy.id,
|
||||
'pharmacy_name': pharmacy.pharmacy_name,
|
||||
'business_number': pharmacy.business_number,
|
||||
'manager_name': pharmacy.manager_name,
|
||||
'phone': pharmacy.phone,
|
||||
'address': pharmacy.address,
|
||||
'proxmox_host': pharmacy.proxmox_host,
|
||||
'headscale_user_name': pharmacy.headscale_user_name,
|
||||
'headscale_user_id': pharmacy.headscale_user_id,
|
||||
'created_at': pharmacy.created_at,
|
||||
'updated_at': pharmacy.updated_at,
|
||||
# 통계 정보
|
||||
'total_machines': len(machine_list),
|
||||
'online_machines': len([m for m in machine_list if m['online']]),
|
||||
'offline_machines': len([m for m in machine_list if not m['online']]),
|
||||
}
|
||||
|
||||
return {
|
||||
'pharmacy': pharmacy.to_dict(),
|
||||
'pharmacy': pharmacy_data,
|
||||
'machines': machine_list
|
||||
}
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"❌ Headscale CLI 오류: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"❌ 약국 상세 정보 조회 오류: {e}")
|
||||
return None
|
||||
finally:
|
||||
close_session(farmq_session)
|
||||
farmq_session.close()
|
||||
|
||||
# ==========================================
|
||||
# Machine Management
|
||||
|
||||
Loading…
Reference in New Issue
Block a user