Implement Headscale machine rename functionality in FarmQ Admin

Add machine name editing feature similar to Headplane:
- REST API endpoint POST /api/machines/{id}/rename with Headscale CLI integration
- Edit button next to each machine name in the machine list
- Modal dialog with real-time Magic DNS preview
- DNS-compatible name validation (lowercase, digits, hyphens only)
- Immediate UI updates after successful rename
- Loading states and error handling

Features:
- farmq-admin/app.py: New rename API endpoint with subprocess Headscale CLI calls
- farmq-admin/templates/machines/list.html: Edit buttons, rename modal, JavaScript functions
- Real-time validation and Magic DNS preview
- Bootstrap modal with form validation
- Error handling with toast notifications

Users can now rename machines and their Magic DNS addresses directly from the web interface,
matching the functionality available in Headplane.

Tested with machine ID 2: desktop-emjd1dc ↔ desktop-emjd1dc-test 

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-13 23:38:57 +09:00
parent a9aa31cc4a
commit c68ed59946
2 changed files with 278 additions and 14 deletions

View File

@@ -112,9 +112,16 @@
{% endif %}
</div>
<div>
<strong>{{ machine_data.machine_name or machine_data.hostname }}</strong>
<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>{{ machine_data.machine_name or machine_data.hostname }}.headscale.local</code>
<i class="fas fa-link"></i> <code id="magicDns-{{ machine_data.id }}">{{ machine_data.machine_name or machine_data.hostname }}.headscale.local</code>
<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>
@@ -460,9 +467,137 @@ function copyToClipboard(text) {
});
}
// 머신 이름 변경 모달 표시
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 %}