feat: separate Pin/Edit/Unpin — one-click pin when hostname exists, inline edit for pinned leases

This commit is contained in:
Claus Lohmar 2026-07-29 09:31:55 +00:00
parent 7793c9edde
commit 2e60e19877
3 changed files with 129 additions and 53 deletions

View file

@ -392,19 +392,25 @@ func handlePinLease(w http.ResponseWriter, r *http.Request) {
return return
} }
// Check if already pinned. // If already pinned, unpin first so we can re-pin with updated hostname.
pinned, err := parsePinnedHosts() pinned, err := parsePinnedHosts()
if err != nil { if err != nil {
log.Printf("ERROR parsing pinned hosts: %v", err) log.Printf("ERROR parsing pinned hosts: %v", err)
writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to read config"}) writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to read config"})
return return
} }
if _, exists := pinned[strings.ToLower(req.MAC)]; exists { if existingHost, exists := pinned[strings.ToLower(req.MAC)]; exists {
writeJSON(w, http.StatusConflict, PinResponse{ if existingHost == req.Hostname {
Success: false, // Same hostname — nothing to change.
Message: fmt.Sprintf("%s is already pinned", req.MAC), writeJSON(w, http.StatusOK, PinResponse{Success: true, Message: "already pinned"})
}) return
return }
// Hostname changed — unpin old entry, then repin.
if err := unpinLease(req.MAC); err != nil {
log.Printf("ERROR unpinning for repin: %v", err)
writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to update config"})
return
}
} }
if err := pinLease(req.MAC, req.IP, req.Hostname); err != nil { if err := pinLease(req.MAC, req.IP, req.Hostname); err != nil {

View file

@ -1,6 +1,6 @@
/** /**
* DHCP Lease Manager client-side logic. * DHCP Lease Manager client-side logic.
* Uses Zoraxy's $.cjax() for CSRF-safe AJAX (jQuery is available from parent frame). * Uses Zoraxy's $.cjax() for CSRF-safe AJAX (jQuery available from parent frame).
*/ */
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -34,6 +34,7 @@ function renderLeases(data) {
var rows = leases.map(function (lease) { var rows = leases.map(function (lease) {
var isPermanent = lease.status === 'permanent'; var isPermanent = lease.status === 'permanent';
var hasHostname = lease.hostname && lease.hostname.trim() !== '';
return ( return (
'<tr>' + '<tr>' +
'<td>' + esc(lease.hostname || '--') + '</td>' + '<td>' + esc(lease.hostname || '--') + '</td>' +
@ -44,15 +45,24 @@ function renderLeases(data) {
esc(lease.status) + esc(lease.status) +
'</span>' + '</span>' +
'</td>' + '</td>' +
'<td class="actions-cell">' + '<td class="actions-cell" data-mac="' + escAttr(lease.mac) + '" data-ip="' + escAttr(lease.ip) + '" data-hostname="' + escAttr(lease.hostname || '') + '">' +
(isPermanent (isPermanent
? '<button class="btn btn-danger" data-action="unpin" data-mac="' + escAttr(lease.mac) + '" data-ip="' + escAttr(lease.ip) + '" data-hostname="' + escAttr(lease.hostname) + '">Unpin</button>' ? '<button class="btn edit-btn" data-action="edit">Edit</button>' +
: '<button class="btn btn-primary pin-btn" data-action="show-pin-form" data-mac="' + escAttr(lease.mac) + '" data-ip="' + escAttr(lease.ip) + '" data-hostname="' + escAttr(lease.hostname) + '">Pin</button>' + '<button class="btn btn-danger" data-action="unpin">Unpin</button>' +
'<span class="pin-form" style="display:none;margin-left:6px;">' + '<span class="edit-form" style="display:none">' +
'<input type="text" class="pin-hostname" placeholder="hostname (optional)" size="14" value="' + escAttr(lease.hostname || '') + '">' + '<input type="text" class="edit-hostname" placeholder="hostname" size="16" value="' + escAttr(lease.hostname || '') + '">' +
'<button class="btn btn-primary pin-confirm" data-mac="' + escAttr(lease.mac) + '" data-ip="' + escAttr(lease.ip) + '">OK</button>' + '<button class="btn btn-primary edit-confirm">OK</button>' +
'<button class="btn btn-secondary pin-cancel">✕</button>' + '<button class="btn edit-cancel">✕</button>' +
'</span>' '</span>'
: (hasHostname
? '<button class="btn btn-primary" data-action="pin">Pin</button>'
: '<button class="btn btn-primary pin-needs-name" data-action="pin-name">Pin</button>' +
'<span class="edit-form" style="display:none">' +
'<input type="text" class="edit-hostname" placeholder="hostname" size="16">' +
'<button class="btn btn-primary edit-confirm">OK</button>' +
'<button class="btn edit-cancel">✕</button>' +
'</span>'
)
) + ) +
'</td>' + '</td>' +
'</tr>' '</tr>'
@ -102,14 +112,14 @@ function pinLease(mac, ip, hostname) {
}); });
} }
function unpinLease(mac, ip, hostname) { function unpinLease(mac) {
$.cjax({ $.cjax({
url: './api/unpin', url: './api/unpin',
type: 'POST', type: 'POST',
dataType: 'json', dataType: 'json',
data: { mac: mac, ip: ip, hostname: hostname || '' }, data: { mac: mac },
success: function () { success: function () {
showToast('Unpinned ' + (hostname || mac), 'success'); showToast('Unpinned', 'success');
loadLeases(); loadLeases();
}, },
error: function (xhr) { error: function (xhr) {
@ -120,6 +130,25 @@ function unpinLease(mac, ip, hostname) {
}); });
} }
function updateHostname(mac, ip, hostname) {
// Re-pin with new hostname (server handles unpin + repin atomically)
$.cjax({
url: './api/pin',
type: 'POST',
dataType: 'json',
data: { mac: mac, ip: ip, hostname: hostname },
success: function () {
showToast('Hostname updated', 'success');
loadLeases();
},
error: function (xhr) {
var msg = 'Update failed';
try { var r = JSON.parse(xhr.responseText); msg = r.message || r.error || msg; } catch(e) {}
showToast(msg, 'error');
}
});
}
function reloadDnsmasq() { function reloadDnsmasq() {
var btn = document.getElementById('reload-btn'); var btn = document.getElementById('reload-btn');
btn.disabled = true; btn.disabled = true;
@ -149,46 +178,75 @@ function reloadDnsmasq() {
}); });
} }
// ---------------------------------------------------------------------------
// Form helpers
// ---------------------------------------------------------------------------
function showEditForm(cell) {
cell.querySelector('.edit-btn').style.display = 'none';
cell.querySelector('.pin-needs-name').style.display = 'none';
cell.querySelector('.edit-form').style.display = 'inline-flex';
var input = cell.querySelector('.edit-hostname');
input.focus();
input.select();
}
function hideEditForm(cell) {
cell.querySelector('.edit-form').style.display = 'none';
var editBtn = cell.querySelector('.edit-btn');
var pinBtn = cell.querySelector('.pin-needs-name');
if (editBtn) editBtn.style.display = 'inline-block';
if (pinBtn) pinBtn.style.display = 'inline-block';
}
function confirmEditForm(cell) {
var mac = cell.dataset.mac;
var ip = cell.dataset.ip;
var hostname = cell.querySelector('.edit-hostname').value.trim();
hideEditForm(cell);
updateHostname(mac, ip, hostname);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Event delegation // Event delegation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
document.getElementById('leases-body').addEventListener('click', function (e) { document.getElementById('leases-body').addEventListener('click', function (e) {
var btn = e.target.closest('button[data-action]'); var cell = e.target.closest('.actions-cell');
if (!btn) return; if (!cell) return;
var action = btn.dataset.action; var mac = cell.dataset.mac;
var mac = btn.dataset.mac; var ip = cell.dataset.ip;
var ip = btn.dataset.ip; var hostname = cell.dataset.hostname;
var hostname = btn.dataset.hostname;
if (action === 'unpin') { // Pin (one-click — hostname already exists)
unpinLease(mac, ip, hostname); if (e.target.closest('[data-action="pin"]')) {
} else if (action === 'show-pin-form') {
// Show inline form, hide the Pin button
var row = btn.closest('td');
row.querySelector('.pin-btn').style.display = 'none';
row.querySelector('.pin-form').style.display = 'inline-flex';
row.querySelector('.pin-hostname').focus();
}
});
document.getElementById('leases-body').addEventListener('click', function (e) {
// Confirm pin
if (e.target.closest('.pin-confirm')) {
var row = e.target.closest('td');
var mac = e.target.dataset.mac;
var ip = e.target.dataset.ip;
var hostname = row.querySelector('.pin-hostname').value.trim();
row.querySelector('.pin-form').style.display = 'none';
row.querySelector('.pin-btn').style.display = 'inline';
pinLease(mac, ip, hostname); pinLease(mac, ip, hostname);
} }
// Cancel pin form
if (e.target.closest('.pin-cancel')) { // Pin (needs name — show form)
var row = e.target.closest('td'); if (e.target.closest('[data-action="pin-name"]')) {
row.querySelector('.pin-form').style.display = 'none'; showEditForm(cell);
row.querySelector('.pin-btn').style.display = 'inline'; }
// Edit (pinned lease — show form to change hostname)
if (e.target.closest('[data-action="edit"]')) {
showEditForm(cell);
}
// Unpin
if (e.target.closest('[data-action="unpin"]')) {
unpinLease(mac);
}
// Confirm edit (OK button)
if (e.target.closest('.edit-confirm')) {
confirmEditForm(cell);
}
// Cancel edit (✕ button)
if (e.target.closest('.edit-cancel')) {
hideEditForm(cell);
} }
}); });

View file

@ -83,12 +83,14 @@ h1 {
cursor: not-allowed; cursor: not-allowed;
} }
.pin-form { .pin-form,
.edit-form {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
} }
.pin-form input[type="text"] { .pin-form input[type="text"],
.edit-form input[type="text"] {
padding: 4px 8px; padding: 4px 8px;
border: 1px solid #0984e3; border: 1px solid #0984e3;
border-radius: 3px; border-radius: 3px;
@ -96,14 +98,24 @@ h1 {
width: 130px; width: 130px;
outline: none; outline: none;
} }
.pin-form input[type="text"]:focus { .pin-form input[type="text"]:focus,
.edit-form input[type="text"]:focus {
border-color: #0773c5; border-color: #0773c5;
box-shadow: 0 0 0 2px rgba(9,132,227,0.2); box-shadow: 0 0 0 2px rgba(9,132,227,0.2);
} }
.pin-form .btn { .pin-form .btn,
.edit-form .btn {
padding: 3px 8px; padding: 3px 8px;
font-size: 12px; font-size: 12px;
} }
.edit-btn {
background: #fdcb6e;
color: #2d3436;
border-color: #fdcb6e;
}
.edit-btn:hover {
background: #f9a825;
}
table { table {
width: 100%; width: 100%;