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
20
server.go
20
server.go
|
|
@ -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 {
|
||||||
|
|
|
||||||
142
web/app.js
142
web/app.js
|
|
@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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%;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue