headscale-tailscale-replace.../farmq-admin/templates/machines/list.html
시골약사 7aa08682b8 Implement smart Magic DNS copy with automatic port detection
### Magic DNS Smart Copy Features:
- **PBS servers**: Automatically append `:8007` port when copying
- **PVE servers**: Automatically append `:8006` port when copying
- **Other machines**: Copy Magic DNS address without port (existing behavior)

### UI Improvements:
- PBS servers: Blue button with `:8007` port hint
- PVE servers: Orange button with `:8006` port hint
- Enhanced tooltips with service-specific port information
- Visual distinction between different server types

### PBS Backup Server Monitoring:
- Complete PBS API integration with authentication
- Real-time backup/restore task monitoring with detailed logs
- Namespace-separated backup visualization with color coding
- Datastore usage monitoring and status tracking
- Task history with success/failure status and error details

### Technical Implementation:
- Smart port detection via JavaScript `addSmartPort()` function
- Jinja2 template logic for conditional button styling
- PBS API endpoints for comprehensive backup monitoring
- Enhanced clipboard functionality with user feedback

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 10:48:47 +09:00

641 lines
30 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>
<div class="d-flex align-items-center">
<strong id="machineName-{{ machine_data.id }}">{{ machine_data.machine_name or machine_data.hostname }}</strong>
<button class="btn btn-sm btn-outline-primary ms-2"
onclick="showRenameModal({{ machine_data.id }}, '{{ machine_data.machine_name or machine_data.hostname }}')"
title="머신 이름 변경">
<i class="fas fa-edit"></i>
</button>
</div>
<div class="small text-success">
<i class="fas fa-link"></i> <code id="magicDns-{{ machine_data.id }}">{{ machine_data.machine_name or machine_data.hostname }}.headscale.local</code>
{% set machine_name = (machine_data.machine_name or machine_data.hostname).lower() %}
{% if 'pbs' in machine_name %}
<button class="btn btn-sm btn-outline-info ms-1" onclick="copyToClipboard('{{ machine_data.machine_name or machine_data.hostname }}.headscale.local')" title="Magic DNS 주소 복사 (PBS 서버 - 포트 8007 자동 추가)">
<i class="fas fa-copy"></i><small class="ms-1">:8007</small>
</button>
{% elif 'pve' in machine_name %}
<button class="btn btn-sm btn-outline-warning ms-1" onclick="copyToClipboard('{{ machine_data.machine_name or machine_data.hostname }}.headscale.local')" title="Magic DNS 주소 복사 (Proxmox VE - 포트 8006 자동 추가)">
<i class="fas fa-copy"></i><small class="ms-1">:8006</small>
</button>
{% else %}
<button class="btn btn-sm btn-outline-secondary ms-1" onclick="copyToClipboard('{{ machine_data.machine_name or machine_data.hostname }}.headscale.local')" title="Magic DNS 주소 복사">
<i class="fas fa-copy"></i>
</button>
{% endif %}
</div>
{% if machine_data.hostname != (machine_data.machine_name or machine_data.hostname) %}
<div class="small text-muted">
<i class="fas fa-server"></i> OS: {{ machine_data.hostname }}
</div>
{% endif %}
<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 %}
<button class="btn btn-outline-danger"
onclick="confirmDeleteNode({{ machine_data.id }}, '{{ machine_data.machine_name or machine_data.hostname }}')"
title="노드 삭제">
<i class="fas fa-trash"></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">아직 등록된 머신이 없습니다. 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>
<button class="btn btn-outline-danger btn-sm"
onclick="confirmDeleteNode({{ machine_data.id }}, '{{ machine_data.machine_name or machine_data.hostname }}')">
<i class="fas fa-trash"></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');
}
// 뷰 변경 시 카운터 업데이트
filterMachines();
});
});
// 머신 검색
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;
// 현재 활성화된 뷰만 선택 (List 또는 Card)
const isListView = document.getElementById('listView').checked;
const activeSelector = isListView ? '.machine-row' : '.machine-card';
document.querySelectorAll(activeSelector).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);
}
// 노드 삭제 확인
function confirmDeleteNode(nodeId, nodeName) {
if (confirm(`정말로 노드 "${nodeName}"를 삭제하시겠습니까?\n\n삭제된 노드는 복구할 수 없으며, 해당 머신은 네트워크에서 완전히 제거됩니다.`)) {
deleteNode(nodeId, nodeName);
}
}
// 노드 삭제 실행
function deleteNode(nodeId, nodeName) {
showToast(`노드 ${nodeName} 삭제 중...`, 'info');
fetch(`/api/nodes/${nodeId}/delete`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast(`노드 ${nodeName}가 성공적으로 삭제되었습니다.`, 'success');
// 페이지 새로고침으로 목록 업데이트
setTimeout(() => {
location.reload();
}, 1500);
} else {
showToast(`노드 삭제 실패: ${data.error}`, 'danger');
}
})
.catch(error => {
console.error('노드 삭제 오류:', error);
showToast('노드 삭제 중 오류가 발생했습니다.', 'danger');
});
}
// 스마트 포트 추가 기능
function addSmartPort(address) {
const lowerAddress = address.toLowerCase();
// PBS가 포함된 경우 :8007 포트 추가
if (lowerAddress.includes('pbs')) {
return address + ':8007';
}
// PVE가 포함된 경우 :8006 포트 추가
if (lowerAddress.includes('pve')) {
return address + ':8006';
}
// 기타 경우는 그대로 반환
return address;
}
// Magic DNS 주소 클립보드 복사 기능 (스마트 포트 지원)
function copyToClipboard(text) {
// 스마트 포트 추가 로직 적용
const enhancedText = addSmartPort(text);
navigator.clipboard.writeText(enhancedText).then(() => {
// 포트가 추가되었는지 확인하여 메시지 표시
if (enhancedText !== text) {
const port = enhancedText.split(':')[1];
showToast(`Magic DNS 주소가 복사되었습니다 (포트 ${port} 자동 추가): ${enhancedText}`, 'success');
} else {
showToast(`Magic DNS 주소가 복사되었습니다: ${enhancedText}`, 'success');
}
}).catch(err => {
console.error('복사 실패:', err);
showToast('복사에 실패했습니다.', 'danger');
});
}
// 머신 이름 변경 모달 표시
function showRenameModal(machineId, currentName) {
document.getElementById('renameModal').dataset.machineId = machineId;
document.getElementById('currentMachineName').textContent = currentName;
document.getElementById('newMachineName').value = currentName;
// 모달 표시
const modal = new bootstrap.Modal(document.getElementById('renameModal'));
modal.show();
}
// 머신 이름 변경 실행
function renameMachine() {
const modal = document.getElementById('renameModal');
const machineId = modal.dataset.machineId;
const newName = document.getElementById('newMachineName').value.trim();
if (!newName) {
showToast('새로운 이름을 입력해주세요.', 'warning');
return;
}
// 이름 유효성 검사
const namePattern = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
if (!namePattern.test(newName)) {
showToast('이름은 소문자, 숫자, 하이픈(-)만 사용 가능하며, 하이픈으로 시작하거나 끝날 수 없습니다.', 'warning');
return;
}
// 로딩 상태 표시
const submitBtn = document.getElementById('renameSubmitBtn');
const originalText = submitBtn.innerHTML;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 변경 중...';
submitBtn.disabled = true;
// API 호출
fetch(`/api/machines/${machineId}/rename`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ new_name: newName })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast(data.message, 'success');
// UI 업데이트
document.getElementById(`machineName-${machineId}`).textContent = data.new_name;
document.getElementById(`magicDns-${machineId}`).textContent = data.new_magic_dns;
// 모달 닫기
bootstrap.Modal.getInstance(document.getElementById('renameModal')).hide();
} else {
showToast(data.error || '이름 변경에 실패했습니다.', 'danger');
}
})
.catch(error => {
console.error('머신 이름 변경 오류:', error);
showToast('서버 오류가 발생했습니다.', 'danger');
})
.finally(() => {
// 로딩 상태 해제
submitBtn.innerHTML = originalText;
submitBtn.disabled = false;
});
}
// 초기 카운터 설정
document.addEventListener('DOMContentLoaded', function() {
filterMachines();
});
</script>
<!-- 머신 이름 변경 모달 -->
<div class="modal fade" id="renameModal" tabindex="-1" aria-labelledby="renameModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="renameModalLabel">
<i class="fas fa-edit"></i> 머신 이름 변경
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">현재 이름</label>
<div class="form-control-plaintext">
<strong id="currentMachineName"></strong>
</div>
</div>
<div class="mb-3">
<label for="newMachineName" class="form-label">새로운 이름</label>
<input type="text" class="form-control" id="newMachineName"
placeholder="새로운 머신 이름을 입력하세요"
pattern="^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"
oninput="updatePreview()">
<div class="form-text">
<i class="fas fa-info-circle"></i>
소문자, 숫자, 하이픈(-)만 사용 가능하며, 하이픈으로 시작하거나 끝날 수 없습니다.
</div>
</div>
<div class="alert alert-info">
<i class="fas fa-magic"></i>
<strong>Magic DNS 주소:</strong>
<span id="previewMagicDns">새이름.headscale.local</span>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
<button type="button" class="btn btn-primary" id="renameSubmitBtn" onclick="renameMachine()">
<i class="fas fa-save"></i> 변경
</button>
</div>
</div>
</div>
</div>
<script>
// Magic DNS 미리보기 업데이트
function updatePreview() {
const newName = document.getElementById('newMachineName').value.trim();
const preview = document.getElementById('previewMagicDns');
if (newName) {
preview.textContent = `${newName}.headscale.local`;
} else {
preview.textContent = '새이름.headscale.local';
}
}
</script>
{% endblock %}