diff --git a/server.go b/server.go
index 6712f79..afc14e2 100644
--- a/server.go
+++ b/server.go
@@ -392,19 +392,25 @@ 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),
- })
- return
+ 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 {
diff --git a/web/app.js b/web/app.js
index dd5531d..bd4ed2f 100644
--- a/web/app.js
+++ b/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 (
'
' +
'| ' + esc(lease.hostname || '--') + ' | ' +
@@ -44,15 +45,24 @@ function renderLeases(data) {
esc(lease.status) +
'' +
'' +
- '' +
+ ' | ' +
(isPermanent
- ? ''
- : '' +
- '' +
- '' +
- '' +
- '' +
+ ? '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
''
+ : (hasHostname
+ ? ''
+ : '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ ''
+ )
) +
' | ' +
'
'
@@ -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);
}
});
diff --git a/web/style.css b/web/style.css
index d97e686..5023e15 100644
--- a/web/style.css
+++ b/web/style.css
@@ -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%;