feat: separate Pin/Edit/Unpin — one-click pin when hostname exists, inline edit for pinned leases
This commit is contained in:
parent
7793c9edde
commit
2e60e19877
3 changed files with 129 additions and 53 deletions
18
server.go
18
server.go
|
|
@ -392,20 +392,26 @@ func handlePinLease(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Check if already pinned.
|
||||
// If already pinned, unpin first so we can re-pin with updated hostname.
|
||||
pinned, err := parsePinnedHosts()
|
||||
if err != nil {
|
||||
log.Printf("ERROR parsing pinned hosts: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to read config"})
|
||||
return
|
||||
}
|
||||
if _, exists := pinned[strings.ToLower(req.MAC)]; exists {
|
||||
writeJSON(w, http.StatusConflict, PinResponse{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("%s is already pinned", req.MAC),
|
||||
})
|
||||
if existingHost, exists := pinned[strings.ToLower(req.MAC)]; exists {
|
||||
if existingHost == req.Hostname {
|
||||
// Same hostname — nothing to change.
|
||||
writeJSON(w, http.StatusOK, PinResponse{Success: true, Message: "already pinned"})
|
||||
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 {
|
||||
log.Printf("ERROR pinning lease: %v", err)
|
||||
|
|
|
|||
142
web/app.js
142
web/app.js
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* 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 isPermanent = lease.status === 'permanent';
|
||||
var hasHostname = lease.hostname && lease.hostname.trim() !== '';
|
||||
return (
|
||||
'<tr>' +
|
||||
'<td>' + esc(lease.hostname || '--') + '</td>' +
|
||||
|
|
@ -44,15 +45,24 @@ function renderLeases(data) {
|
|||
esc(lease.status) +
|
||||
'</span>' +
|
||||
'</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
|
||||
? '<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 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>' +
|
||||
'<span class="pin-form" style="display:none;margin-left:6px;">' +
|
||||
'<input type="text" class="pin-hostname" placeholder="hostname (optional)" size="14" 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-secondary pin-cancel">✕</button>' +
|
||||
? '<button class="btn edit-btn" data-action="edit">Edit</button>' +
|
||||
'<button class="btn btn-danger" data-action="unpin">Unpin</button>' +
|
||||
'<span class="edit-form" style="display:none">' +
|
||||
'<input type="text" class="edit-hostname" placeholder="hostname" size="16" value="' + escAttr(lease.hostname || '') + '">' +
|
||||
'<button class="btn btn-primary edit-confirm">OK</button>' +
|
||||
'<button class="btn edit-cancel">✕</button>' +
|
||||
'</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>' +
|
||||
'</tr>'
|
||||
|
|
@ -102,14 +112,14 @@ function pinLease(mac, ip, hostname) {
|
|||
});
|
||||
}
|
||||
|
||||
function unpinLease(mac, ip, hostname) {
|
||||
function unpinLease(mac) {
|
||||
$.cjax({
|
||||
url: './api/unpin',
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: { mac: mac, ip: ip, hostname: hostname || '' },
|
||||
data: { mac: mac },
|
||||
success: function () {
|
||||
showToast('Unpinned ' + (hostname || mac), 'success');
|
||||
showToast('Unpinned', 'success');
|
||||
loadLeases();
|
||||
},
|
||||
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() {
|
||||
var btn = document.getElementById('reload-btn');
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
document.getElementById('leases-body').addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('button[data-action]');
|
||||
if (!btn) return;
|
||||
var cell = e.target.closest('.actions-cell');
|
||||
if (!cell) return;
|
||||
|
||||
var action = btn.dataset.action;
|
||||
var mac = btn.dataset.mac;
|
||||
var ip = btn.dataset.ip;
|
||||
var hostname = btn.dataset.hostname;
|
||||
var mac = cell.dataset.mac;
|
||||
var ip = cell.dataset.ip;
|
||||
var hostname = cell.dataset.hostname;
|
||||
|
||||
if (action === 'unpin') {
|
||||
unpinLease(mac, ip, hostname);
|
||||
} 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';
|
||||
// Pin (one-click — hostname already exists)
|
||||
if (e.target.closest('[data-action="pin"]')) {
|
||||
pinLease(mac, ip, hostname);
|
||||
}
|
||||
// Cancel pin form
|
||||
if (e.target.closest('.pin-cancel')) {
|
||||
var row = e.target.closest('td');
|
||||
row.querySelector('.pin-form').style.display = 'none';
|
||||
row.querySelector('.pin-btn').style.display = 'inline';
|
||||
|
||||
// Pin (needs name — show form)
|
||||
if (e.target.closest('[data-action="pin-name"]')) {
|
||||
showEditForm(cell);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -83,12 +83,14 @@ h1 {
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pin-form {
|
||||
.pin-form,
|
||||
.edit-form {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.pin-form input[type="text"] {
|
||||
.pin-form input[type="text"],
|
||||
.edit-form input[type="text"] {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #0984e3;
|
||||
border-radius: 3px;
|
||||
|
|
@ -96,14 +98,24 @@ h1 {
|
|||
width: 130px;
|
||||
outline: none;
|
||||
}
|
||||
.pin-form input[type="text"]:focus {
|
||||
.pin-form input[type="text"]:focus,
|
||||
.edit-form input[type="text"]:focus {
|
||||
border-color: #0773c5;
|
||||
box-shadow: 0 0 0 2px rgba(9,132,227,0.2);
|
||||
}
|
||||
.pin-form .btn {
|
||||
.pin-form .btn,
|
||||
.edit-form .btn {
|
||||
padding: 3px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.edit-btn {
|
||||
background: #fdcb6e;
|
||||
color: #2d3436;
|
||||
border-color: #fdcb6e;
|
||||
}
|
||||
.edit-btn:hover {
|
||||
background: #f9a825;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
|
|
|
|||
Loading…
Reference in a new issue