## Features - 한국어 Flask 관리 인터페이스 with Bootstrap 5 - Headscale과 분리된 독립 데이터베이스 구조 - 약국 관리 시스템 (pharmacy management) - 머신 모니터링 및 상태 관리 - 실시간 대시보드 with 통계 및 알림 - Headscale 사용자명과 약국명 분리 관리 ## Database Architecture - 별도 FARMQ SQLite DB (farmq.sqlite) - Headscale DB와 외래키 충돌 방지 - 느슨한 결합 설계 (ID 참조만 사용) ## UI Components - 반응형 대시보드 with 실시간 통계 - 약국별 머신 상태 모니터링 - 한국어 지역화 및 사용자 친화적 인터페이스 - 머신 온라인/오프라인 상태 표시 (24시간 타임아웃) ## API Endpoints - `/api/sync/machines` - Headscale 머신 동기화 - `/api/sync/users` - Headscale 사용자 동기화 - `/api/pharmacy/<id>/update` - 약국 정보 업데이트 - 대시보드 통계 및 알림 API ## Problem Resolution - Fixed foreign key conflicts preventing Windows client connections - Resolved machine online status detection with proper timeout handling - Separated technical Headscale usernames from business pharmacy names 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
388 lines
16 KiB
HTML
388 lines
16 KiB
HTML
{% 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('machine_list') }}">머신 관리</a></li>
|
|
<li class="breadcrumb-item active">{{ machine.given_name or machine.hostname }}</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 is_online %}
|
|
<i class="fas fa-desktop fa-3x text-success"></i>
|
|
{% else %}
|
|
<i class="fas fa-desktop fa-3x text-muted"></i>
|
|
{% endif %}
|
|
</div>
|
|
<div>
|
|
<h1 class="h2 mb-0">{{ machine.given_name or machine.hostname }}</h1>
|
|
<p class="text-muted mb-1">{{ machine.hostname }}</p>
|
|
<div class="d-flex gap-2 align-items-center">
|
|
{% if is_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 %}
|
|
<small class="text-muted">마지막 접속: {{ last_seen_humanized }}</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<button class="btn btn-outline-secondary" onclick="refreshMachineDetail()">
|
|
<i class="fas fa-sync-alt"></i> 새로고침
|
|
</button>
|
|
{% if is_online %}
|
|
<button class="btn btn-outline-warning">
|
|
<i class="fas fa-redo"></i> 재시작
|
|
</button>
|
|
{% endif %}
|
|
<button class="btn btn-outline-info" onclick="showMonitoringModal()">
|
|
<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%">머신 ID</th>
|
|
<td>{{ machine.id }}</td>
|
|
</tr>
|
|
<tr>
|
|
<th>호스트명</th>
|
|
<td>{{ machine.hostname }}</td>
|
|
</tr>
|
|
<tr>
|
|
<th>표시 이름</th>
|
|
<td>{{ machine.given_name or '미설정' }}</td>
|
|
</tr>
|
|
<tr>
|
|
<th>사용자</th>
|
|
<td>
|
|
{% if machine.user %}
|
|
<span class="badge bg-primary">{{ machine.user.name }}</span>
|
|
{% else %}
|
|
<span class="text-muted">미지정</span>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<th>등록 방식</th>
|
|
<td>{{ machine.register_method or '알 수 없음' }}</td>
|
|
</tr>
|
|
<tr>
|
|
<th>등록일</th>
|
|
<td>{{ machine.created_at.strftime('%Y년 %m월 %d일 %H:%M') if machine.created_at else '알 수 없음' }}</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%">IPv4 주소</th>
|
|
<td><code>{{ machine.ipv4 }}</code></td>
|
|
</tr>
|
|
{% if machine.ipv6 %}
|
|
<tr>
|
|
<th>IPv6 주소</th>
|
|
<td><code class="small">{{ machine.ipv6 }}</code></td>
|
|
</tr>
|
|
{% endif %}
|
|
<tr>
|
|
<th>엔드포인트</th>
|
|
<td>
|
|
{% if machine.get_endpoints() %}
|
|
<div class="small">
|
|
{% for endpoint in machine.get_endpoints()[:3] %}
|
|
<div><code>{{ endpoint }}</code></div>
|
|
{% endfor %}
|
|
{% if machine.get_endpoints()|length > 3 %}
|
|
<div class="text-muted">... 및 {{ machine.get_endpoints()|length - 3 }}개 더</div>
|
|
{% endif %}
|
|
</div>
|
|
{% else %}
|
|
<span class="text-muted">없음</span>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<th>마지막 접속</th>
|
|
<td>
|
|
{% if machine.last_seen %}
|
|
{{ machine.last_seen.strftime('%Y-%m-%d %H:%M:%S') }}
|
|
<br><small class="text-muted">{{ last_seen_humanized }}</small>
|
|
{% else %}
|
|
<span class="text-muted">알 수 없음</span>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 하드웨어 사양 -->
|
|
{% if specs %}
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="mb-0"><i class="fas fa-microchip"></i> 하드웨어 사양</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row">
|
|
<div class="col-md-3">
|
|
<div class="text-center">
|
|
<i class="fas fa-microchip fa-2x text-primary mb-2"></i>
|
|
<h6>CPU</h6>
|
|
<p class="mb-1">{{ specs.cpu_model }}</p>
|
|
<small class="text-muted">{{ specs.cpu_cores }}코어</small>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="text-center">
|
|
<i class="fas fa-memory fa-2x text-success mb-2"></i>
|
|
<h6>메모리</h6>
|
|
<p class="mb-1">{{ specs.ram_gb }}GB</p>
|
|
<small class="text-muted">RAM</small>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="text-center">
|
|
<i class="fas fa-hdd fa-2x text-warning mb-2"></i>
|
|
<h6>저장소</h6>
|
|
<p class="mb-1">{{ specs.storage_gb }}GB</p>
|
|
<small class="text-muted">디스크</small>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="text-center">
|
|
<i class="fas fa-wifi fa-2x text-info mb-2"></i>
|
|
<h6>네트워크</h6>
|
|
<p class="mb-1">{{ specs.network_speed }}Mbps</p>
|
|
<small class="text-muted">{{ specs.os_info or '알 수 없음' }}</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- 현재 상태 모니터링 -->
|
|
{% if latest_monitoring %}
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<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>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row">
|
|
<div class="col-md-3">
|
|
<div class="text-center">
|
|
<canvas id="cpuChart" width="100" height="100"></canvas>
|
|
<h6 class="mt-2">CPU 사용률</h6>
|
|
<span class="h4">{{ "%.1f"|format(latest_monitoring.cpu_usage|float) }}%</span>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="text-center">
|
|
<canvas id="memoryChart" width="100" height="100"></canvas>
|
|
<h6 class="mt-2">메모리 사용률</h6>
|
|
<span class="h4">{{ "%.1f"|format(latest_monitoring.memory_usage|float) }}%</span>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="text-center">
|
|
<canvas id="diskChart" width="100" height="100"></canvas>
|
|
<h6 class="mt-2">디스크 사용률</h6>
|
|
<span class="h4">{{ "%.1f"|format(latest_monitoring.disk_usage|float) }}%</span>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="text-center">
|
|
<div class="mb-2">
|
|
<i class="fas fa-thermometer-half fa-3x
|
|
{% if latest_monitoring.cpu_temperature > 80 %}text-danger
|
|
{% elif latest_monitoring.cpu_temperature > 70 %}text-warning
|
|
{% else %}text-success{% endif %}"></i>
|
|
</div>
|
|
<h6>CPU 온도</h6>
|
|
<span class="h4
|
|
{% if latest_monitoring.cpu_temperature > 80 %}text-danger
|
|
{% elif latest_monitoring.cpu_temperature > 70 %}text-warning
|
|
{% else %}text-success{% endif %}">
|
|
{{ latest_monitoring.cpu_temperature }}°C
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- 소속 약국 정보 -->
|
|
{% if pharmacy %}
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="mb-0"><i class="fas fa-store"></i> 소속 약국</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<h6>{{ pharmacy.pharmacy_name }}</h6>
|
|
<p class="text-muted">{{ pharmacy.address or '주소 미등록' }}</p>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="d-flex justify-content-between">
|
|
<div>
|
|
<strong>담당자:</strong> {{ pharmacy.manager_name or '미등록' }}
|
|
</div>
|
|
<div>
|
|
<strong>연락처:</strong> {{ pharmacy.phone or '미등록' }}
|
|
</div>
|
|
</div>
|
|
<div class="mt-2">
|
|
<a href="{{ url_for('pharmacy_detail', pharmacy_id=pharmacy.id) }}"
|
|
class="btn btn-outline-primary btn-sm">
|
|
<i class="fas fa-eye"></i> 약국 상세 보기
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- 실시간 모니터링 모달 -->
|
|
<div class="modal fade" id="monitoringModal" tabindex="-1">
|
|
<div class="modal-dialog modal-xl">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">
|
|
<i class="fas fa-chart-line"></i> 실시간 모니터링 - {{ machine.hostname }}
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<canvas id="realtimeCpuChart"></canvas>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<canvas id="realtimeMemoryChart"></canvas>
|
|
</div>
|
|
</div>
|
|
<div class="mt-3 text-center">
|
|
<div id="monitoringStatus" class="alert alert-info">
|
|
실시간 데이터를 불러오는 중...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">닫기</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
let monitoringModal;
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
monitoringModal = new bootstrap.Modal(document.getElementById('monitoringModal'));
|
|
|
|
{% if latest_monitoring %}
|
|
// 도넛 차트 생성
|
|
createDoughnutChart('cpuChart', {{ latest_monitoring.cpu_usage|float }}, 'CPU', '#007bff');
|
|
createDoughnutChart('memoryChart', {{ latest_monitoring.memory_usage|float }}, 'Memory', '#28a745');
|
|
createDoughnutChart('diskChart', {{ latest_monitoring.disk_usage|float }}, 'Disk', '#ffc107');
|
|
{% endif %}
|
|
});
|
|
|
|
function refreshMachineDetail() {
|
|
showToast('머신 정보를 새로고침 중...', 'info');
|
|
setTimeout(() => {
|
|
location.reload();
|
|
}, 1000);
|
|
}
|
|
|
|
function showMonitoringModal() {
|
|
monitoringModal.show();
|
|
loadRealtimeData();
|
|
}
|
|
|
|
function loadRealtimeData() {
|
|
fetch(`/api/machines/{{ machine.id }}/monitoring`)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
document.getElementById('monitoringStatus').innerHTML =
|
|
`<i class="fas fa-check-circle"></i> 최근 ${data.length}개 데이터 포인트 로드됨`;
|
|
document.getElementById('monitoringStatus').className = 'alert alert-success';
|
|
|
|
// 실시간 차트 업데이트 (구현 예정)
|
|
console.log('Monitoring data:', data);
|
|
})
|
|
.catch(error => {
|
|
document.getElementById('monitoringStatus').innerHTML =
|
|
`<i class="fas fa-exclamation-triangle"></i> 데이터 로드 실패: ${error.message}`;
|
|
document.getElementById('monitoringStatus').className = 'alert alert-danger';
|
|
});
|
|
}
|
|
|
|
// 10초마다 현재 상태 업데이트
|
|
setInterval(() => {
|
|
if ({{ machine.id }}) {
|
|
updateCurrentStatus({{ machine.id }});
|
|
}
|
|
}, 10000);
|
|
|
|
function updateCurrentStatus(machineId) {
|
|
// 실시간 상태 업데이트 구현 (향후)
|
|
}
|
|
</script>
|
|
{% endblock %} |