headscale-tailscale-replace.../farmq-admin/templates/dashboard/index.html
시골약사 ca61a89739 🏥 Add complete FARMQ Admin Flask application
## 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>
2025-09-09 17:44:56 +09:00

277 lines
12 KiB
HTML

{% extends "base.html" %}
{% block title %}대시보드 - 팜큐 약국 관리 시스템{% endblock %}
{% block breadcrumb %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item active">
<i class="fas fa-tachometer-alt"></i> 대시보드
</li>
</ol>
</nav>
{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-12">
<h1 class="h2 mb-0">
<i class="fas fa-tachometer-alt text-primary"></i>
대시보드
</h1>
<p class="text-muted">팜큐 약국 네트워크 전체 현황</p>
</div>
</div>
<!-- 통계 카드 -->
<div class="row mb-4">
<div class="col-lg-3 col-md-6 mb-3">
<div class="card stat-card">
<div class="card-body text-center">
<div class="stat-number" id="total-pharmacies">{{ stats.total_pharmacies }}</div>
<div class="stat-label">
<i class="fas fa-store"></i> 총 약국 수
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 mb-3">
<div class="card" style="background: linear-gradient(135deg, #48bb78 0%, #38a169 100%); color: white;">
<div class="card-body text-center">
<div class="stat-number" id="online-machines">{{ stats.online_machines }}</div>
<div class="stat-label">
<i class="fas fa-circle text-success"></i> 온라인 머신
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 mb-3">
<div class="card" style="background: linear-gradient(135deg, #f56565 0%, #e53e3e 100%); color: white;">
<div class="card-body text-center">
<div class="stat-number" id="offline-machines">{{ stats.offline_machines }}</div>
<div class="stat-label">
<i class="fas fa-circle text-danger"></i> 오프라인 머신
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 mb-3">
<div class="card" style="background: linear-gradient(135deg, #ed8936 0%, #dd6b20 100%); color: white;">
<div class="card-body text-center">
<div class="stat-number" id="avg-temp">{{ stats.avg_cpu_temp }}°C</div>
<div class="stat-label">
<i class="fas fa-thermometer-half"></i> 평균 CPU 온도
</div>
</div>
</div>
</div>
</div>
<div class="row">
<!-- 실시간 알림 -->
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-exclamation-triangle text-warning"></i> 실시간 알림
</h5>
<span class="badge bg-primary">{{ stats.alerts|length }}</span>
</div>
<div class="card-body">
{% if stats.alerts %}
{% for alert in stats.alerts %}
<div class="alert-item p-3 mb-2 bg-light {% if alert.type == 'warning' %}alert-warning{% elif alert.type == 'danger' %}alert-danger{% endif %}">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>
{% if alert.level == 'high_temperature' %}
<i class="fas fa-thermometer-full text-danger"></i>
{% elif alert.level == 'high_disk' %}
<i class="fas fa-hdd text-warning"></i>
{% else %}
<i class="fas fa-exclamation-triangle"></i>
{% endif %}
{{ alert.machine.hostname }}
</strong>
<div class="small text-muted">{{ alert.message }}</div>
</div>
<div class="text-end">
<span class="badge bg-{{ alert.type }}">
{{ alert.value }}{% if alert.level == 'high_temperature' %}°C{% else %}%{% endif %}
</span>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center text-muted py-4">
<i class="fas fa-check-circle fa-3x mb-3 text-success"></i>
<p>모든 시스템이 정상 작동 중입니다.</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- 성능 차트 -->
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-chart-pie text-info"></i> 전체 성능 현황
</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6 mb-3">
<div class="position-relative">
<canvas id="cpuChart" width="100" height="100"></canvas>
<div class="position-absolute top-50 start-50 translate-middle">
<div class="fw-bold">{{ "%.1f"|format(stats.avg_cpu_temp) }}°C</div>
<div class="small text-muted">CPU</div>
</div>
</div>
</div>
<div class="col-6 mb-3">
<div class="position-relative">
<canvas id="memoryChart" width="100" height="100"></canvas>
<div class="position-absolute top-50 start-50 translate-middle">
<div class="fw-bold">75.0%</div>
<div class="small text-muted">메모리</div>
</div>
</div>
</div>
<div class="col-6">
<div class="position-relative">
<canvas id="diskChart" width="100" height="100"></canvas>
<div class="position-absolute top-50 start-50 translate-middle">
<div class="fw-bold">60.0%</div>
<div class="small text-muted">디스크</div>
</div>
</div>
</div>
<div class="col-6">
<div class="text-center">
<div class="display-4">🌡️</div>
<div class="fw-bold">{{ "%.1f"|format(stats.avg_cpu_temp) }}°C</div>
<div class="small text-muted">평균 온도</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 약국별 상태 -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-store text-primary"></i> 약국별 상태
</h5>
<a href="{{ url_for('pharmacy_list') }}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-list"></i> 전체 보기
</a>
</div>
<div class="card-body">
{% if pharmacies %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>약국명</th>
<th>Headscale 사용자</th>
<th>사업자번호</th>
<th>연결된 머신</th>
<th>온라인 상태</th>
<th>액션</th>
</tr>
</thead>
<tbody>
{% for pharmacy_data in pharmacies %}
<tr>
<td>
<strong>{{ pharmacy_data.pharmacy_name }}</strong><br>
<small class="text-muted">{{ pharmacy_data.manager_name }}</small>
</td>
<td>
<code class="text-primary">{{ pharmacy_data.headscale_user_name }}</code>
</td>
<td>{{ pharmacy_data.business_number }}</td>
<td>
<span class="badge bg-info">{{ pharmacy_data.machine_count }}대</span>
</td>
<td>
<div class="d-flex align-items-center">
<div class="progress me-2" style="width: 100px; height: 8px;">
<div class="progress-bar bg-success"
style="width: {{ (pharmacy_data.online_count / pharmacy_data.machine_count * 100) if pharmacy_data.machine_count > 0 else 0 }}%"></div>
</div>
<small>{{ pharmacy_data.online_count }}/{{ pharmacy_data.machine_count }}</small>
</div>
</td>
<td>
<div class="btn-group btn-group-sm">
<a href="{{ url_for('pharmacy_detail', pharmacy_id=pharmacy_data.id) }}"
class="btn btn-outline-primary">상세</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-4">
<i class="fas fa-store fa-3x mb-3"></i>
<p>등록된 약국이 없습니다.</p>
<a href="{{ url_for('pharmacy_list') }}" class="btn btn-primary">
<i class="fas fa-plus"></i> 약국 등록하기
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// 성능 차트 생성
document.addEventListener('DOMContentLoaded', function() {
createDoughnutChart('cpuChart', {{ stats.avg_cpu_temp }}, '온도', '#3b82f6');
createDoughnutChart('memoryChart', 75, '메모리', '#10b981');
createDoughnutChart('diskChart', 60, '디스크', '#f59e0b');
});
// 실시간 알림 업데이트
function updateAlerts() {
fetch('/api/alerts')
.then(response => response.json())
.then(alerts => {
// 알림 개수 업데이트
const alertBadge = document.querySelector('.card-header .badge');
if (alertBadge) {
alertBadge.textContent = alerts.length;
}
// 새로운 알림이 있으면 토스트 표시
alerts.forEach(alert => {
if (!document.querySelector(`[data-machine-id="${alert.machine.id}"]`)) {
showToast(`${alert.machine.hostname}: ${alert.message}`, alert.type);
}
});
})
.catch(error => console.error('Alert update failed:', error));
}
// 알림 업데이트 (30초마다)
setInterval(updateAlerts, 30000);
</script>
{% endblock %}