## 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>
397 lines
19 KiB
HTML
397 lines
19 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 active">머신 관리</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>
|
|
<h1 class="h2 mb-0">
|
|
<i class="fas fa-desktop text-primary"></i>
|
|
머신 관리
|
|
</h1>
|
|
<p class="text-muted">연결된 모든 머신의 상태 및 하드웨어 정보</p>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<button class="btn btn-outline-secondary" onclick="refreshMachineList()">
|
|
<i class="fas fa-sync-alt"></i> 새로고침
|
|
</button>
|
|
<div class="btn-group" role="group">
|
|
<input type="radio" class="btn-check" name="viewMode" id="listView" autocomplete="off" checked>
|
|
<label class="btn btn-outline-primary" for="listView">
|
|
<i class="fas fa-list"></i> 목록
|
|
</label>
|
|
|
|
<input type="radio" class="btn-check" name="viewMode" id="cardView" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="cardView">
|
|
<i class="fas fa-th-large"></i> 카드
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 필터 및 검색 -->
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="row align-items-center">
|
|
<div class="col-md-3 mb-2">
|
|
<div class="input-group">
|
|
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
|
<input type="text" class="form-control" id="searchMachine" placeholder="머신 검색...">
|
|
</div>
|
|
</div>
|
|
<div class="col-md-2 mb-2">
|
|
<select class="form-select" id="filterStatus">
|
|
<option value="">전체 상태</option>
|
|
<option value="online">온라인</option>
|
|
<option value="offline">오프라인</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2 mb-2">
|
|
<select class="form-select" id="filterPharmacy">
|
|
<option value="">전체 약국</option>
|
|
<!-- 약국 목록은 동적으로 로드 -->
|
|
</select>
|
|
</div>
|
|
<div class="col-md-3 mb-2">
|
|
<div class="d-flex gap-2">
|
|
<span class="badge bg-success">온라인: <span id="onlineCount">0</span></span>
|
|
<span class="badge bg-danger">오프라인: <span id="offlineCount">0</span></span>
|
|
<span class="badge bg-secondary">전체: <span id="totalCount">0</span></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 머신 목록 (테이블 뷰) -->
|
|
<div id="listView" class="machine-view">
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
{% if machines %}
|
|
<div class="table-responsive">
|
|
<table class="table table-hover">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>머신 정보</th>
|
|
<th>네트워크</th>
|
|
<th>하드웨어</th>
|
|
<th>상태</th>
|
|
<th>소속 약국</th>
|
|
<th>액션</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for machine_data in machines %}
|
|
<tr class="machine-row" data-status="{% if machine_data.is_online %}online{% else %}offline{% endif %}">
|
|
<td>
|
|
<div class="d-flex align-items-center">
|
|
<div class="me-3">
|
|
{% if machine_data.is_online %}
|
|
<i class="fas fa-desktop fa-2x text-success"></i>
|
|
{% else %}
|
|
<i class="fas fa-desktop fa-2x text-muted"></i>
|
|
{% endif %}
|
|
</div>
|
|
<div>
|
|
<strong>{{ machine_data.machine_name or machine_data.hostname }}</strong>
|
|
<div class="small text-muted">{{ machine_data.hostname }}</div>
|
|
<div class="small">
|
|
<i class="fas fa-user"></i> {{ machine_data.headscale_user_name or '미지정' }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div>
|
|
<code class="small">{{ machine_data.tailscale_ip }}</code>
|
|
</div>
|
|
{% if machine_data.ipv6 %}
|
|
<div>
|
|
<code class="small text-muted">{{ machine_data.ipv6 }}</code>
|
|
</div>
|
|
{% endif %}
|
|
<div class="small text-muted">
|
|
엔드포인트: 0개
|
|
</div>
|
|
</td>
|
|
<td>
|
|
{% if machine_data.specs %}
|
|
<div class="small">
|
|
<div><i class="fas fa-microchip"></i> {{ machine_data.specs.cpu_model[:20] }}{% if machine_data.specs.cpu_model|length > 20 %}...{% endif %}</div>
|
|
<div><i class="fas fa-memory"></i> {{ machine_data.specs.ram_gb }}GB RAM</div>
|
|
<div><i class="fas fa-hdd"></i> {{ machine_data.specs.storage_gb }}GB</div>
|
|
</div>
|
|
{% else %}
|
|
<span class="text-muted small">정보 없음</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
<div class="d-flex flex-column">
|
|
{% if machine_data.is_online %}
|
|
<span class="badge bg-success mb-1">
|
|
<i class="fas fa-circle"></i> 온라인
|
|
</span>
|
|
{% else %}
|
|
<span class="badge bg-danger mb-1">
|
|
<i class="fas fa-circle"></i> 오프라인
|
|
</span>
|
|
{% endif %}
|
|
|
|
{% if machine_data.latest_monitoring %}
|
|
<div class="small">
|
|
<div>CPU: {{ machine_data.latest_monitoring.cpu_usage }}%</div>
|
|
<div>온도: {{ machine_data.latest_monitoring.cpu_temperature }}°C</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
{% if machine_data.pharmacy %}
|
|
<div>
|
|
<strong>{{ machine_data.pharmacy.pharmacy_name }}</strong>
|
|
<div class="small text-muted">{{ machine_data.pharmacy.manager_name }}</div>
|
|
</div>
|
|
{% 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_data.id) }}"
|
|
class="btn btn-outline-primary" title="상세 정보">
|
|
<i class="fas fa-eye"></i>
|
|
</a>
|
|
<button class="btn btn-outline-info"
|
|
onclick="showMonitoring({{ machine_data.id }})" title="모니터링">
|
|
<i class="fas fa-chart-line"></i>
|
|
</button>
|
|
{% if machine_data.is_online %}
|
|
<button class="btn btn-outline-warning" title="재시작">
|
|
<i class="fas fa-redo"></i>
|
|
</button>
|
|
{% endif %}
|
|
</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">아직 등록된 머신이 없습니다. Headscale에 머신을 연결해주세요.</p>
|
|
<a href="http://localhost:3000/admin/" target="_blank" class="btn btn-primary">
|
|
<i class="fas fa-external-link-alt"></i> Headplane에서 머신 등록
|
|
</a>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 머신 목록 (카드 뷰) -->
|
|
<div id="cardView" class="machine-view d-none">
|
|
<div class="row">
|
|
{% for machine_data in machines %}
|
|
<div class="col-lg-4 col-md-6 mb-4">
|
|
<div class="card h-100 machine-card" data-status="{% if machine_data.is_online %}online{% else %}offline{% endif %}">
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
|
<div>
|
|
<h5 class="card-title mb-1">{{ machine_data.machine_name or machine_data.hostname }}</h5>
|
|
<p class="card-text text-muted small">{{ machine_data.hostname }}</p>
|
|
</div>
|
|
{% if machine_data.is_online %}
|
|
<span class="badge bg-success">온라인</span>
|
|
{% else %}
|
|
<span class="badge bg-danger">오프라인</span>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<div class="small mb-2">
|
|
<i class="fas fa-network-wired"></i> {{ machine_data.tailscale_ip }}
|
|
</div>
|
|
{% if machine_data.pharmacy %}
|
|
<div class="small mb-2">
|
|
<i class="fas fa-store"></i> {{ machine_data.pharmacy.pharmacy_name }}
|
|
</div>
|
|
{% endif %}
|
|
<div class="small">
|
|
<i class="fas fa-clock"></i> {{ machine_data.last_seen_humanized }}
|
|
</div>
|
|
</div>
|
|
|
|
{% if machine_data.specs %}
|
|
<div class="mb-3">
|
|
<hr>
|
|
<div class="row text-center">
|
|
<div class="col-4">
|
|
<div class="small text-muted">CPU</div>
|
|
<div class="small">{{ machine_data.specs.cpu_cores }}코어</div>
|
|
</div>
|
|
<div class="col-4">
|
|
<div class="small text-muted">RAM</div>
|
|
<div class="small">{{ machine_data.specs.ram_gb }}GB</div>
|
|
</div>
|
|
<div class="col-4">
|
|
<div class="small text-muted">Storage</div>
|
|
<div class="small">{{ machine_data.specs.storage_gb }}GB</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if machine_data.latest_monitoring %}
|
|
<div class="mb-3">
|
|
<div class="row">
|
|
<div class="col-6">
|
|
<div class="small text-muted">CPU 사용률</div>
|
|
<div class="progress" style="height: 6px;">
|
|
<div class="progress-bar bg-primary"
|
|
style="width: {{ machine_data.latest_monitoring.cpu_usage }}%"></div>
|
|
</div>
|
|
<div class="small">{{ machine_data.latest_monitoring.cpu_usage }}%</div>
|
|
</div>
|
|
<div class="col-6">
|
|
<div class="small text-muted">온도</div>
|
|
<div class="text-center">
|
|
<span class="h6 {% if machine_data.latest_monitoring.cpu_temperature > 80 %}text-danger{% elif machine_data.latest_monitoring.cpu_temperature > 70 %}text-warning{% else %}text-success{% endif %}">
|
|
{{ machine_data.latest_monitoring.cpu_temperature }}°C
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
<div class="card-footer bg-transparent">
|
|
<div class="d-grid gap-2 d-md-block">
|
|
<a href="{{ url_for('machine_detail', machine_id=machine_data.id) }}"
|
|
class="btn btn-outline-primary btn-sm">
|
|
<i class="fas fa-eye"></i> 상세
|
|
</a>
|
|
<button class="btn btn-outline-info btn-sm"
|
|
onclick="showMonitoring({{ machine_data.id }})">
|
|
<i class="fas fa-chart-line"></i> 모니터링
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
// 뷰 모드 전환
|
|
document.querySelectorAll('input[name="viewMode"]').forEach(radio => {
|
|
radio.addEventListener('change', function() {
|
|
document.querySelectorAll('.machine-view').forEach(view => {
|
|
view.classList.add('d-none');
|
|
});
|
|
|
|
if (this.id === 'listView') {
|
|
document.getElementById('listView').classList.remove('d-none');
|
|
} else {
|
|
document.getElementById('cardView').classList.remove('d-none');
|
|
}
|
|
});
|
|
});
|
|
|
|
// 머신 검색
|
|
document.getElementById('searchMachine').addEventListener('input', function() {
|
|
const searchTerm = this.value.toLowerCase();
|
|
filterMachines();
|
|
});
|
|
|
|
// 상태 필터
|
|
document.getElementById('filterStatus').addEventListener('change', function() {
|
|
filterMachines();
|
|
});
|
|
|
|
function filterMachines() {
|
|
const searchTerm = document.getElementById('searchMachine').value.toLowerCase();
|
|
const statusFilter = document.getElementById('filterStatus').value;
|
|
|
|
let visibleCount = 0;
|
|
let onlineCount = 0;
|
|
let offlineCount = 0;
|
|
|
|
document.querySelectorAll('.machine-row, .machine-card').forEach(element => {
|
|
const machineText = element.textContent.toLowerCase();
|
|
const machineStatus = element.dataset.status;
|
|
|
|
let showElement = true;
|
|
|
|
// 검색어 필터
|
|
if (searchTerm && !machineText.includes(searchTerm)) {
|
|
showElement = false;
|
|
}
|
|
|
|
// 상태 필터
|
|
if (statusFilter && machineStatus !== statusFilter) {
|
|
showElement = false;
|
|
}
|
|
|
|
if (showElement) {
|
|
element.style.display = '';
|
|
visibleCount++;
|
|
if (machineStatus === 'online') onlineCount++;
|
|
else offlineCount++;
|
|
} else {
|
|
element.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
// 카운터 업데이트
|
|
document.getElementById('onlineCount').textContent = onlineCount;
|
|
document.getElementById('offlineCount').textContent = offlineCount;
|
|
document.getElementById('totalCount').textContent = visibleCount;
|
|
}
|
|
|
|
// 모니터링 모달
|
|
function showMonitoring(machineId) {
|
|
// TODO: 모니터링 모달 구현
|
|
showToast(`머신 ${machineId} 모니터링 기능 준비 중`, 'info');
|
|
}
|
|
|
|
// 머신 목록 새로고침
|
|
function refreshMachineList() {
|
|
showToast('머신 목록을 새로고침 중...', 'info');
|
|
setTimeout(() => {
|
|
location.reload();
|
|
}, 1000);
|
|
}
|
|
|
|
// 초기 카운터 설정
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
filterMachines();
|
|
});
|
|
</script>
|
|
{% endblock %} |