Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
fbed99b
Add static DHCP lease editing table fully synced with the advanced te…
DL6ER Jul 15, 2025
2495a8e
Add copy from current to static DHCP leases button
DL6ER Jul 15, 2025
937cd04
Add tooltips to the action buttons
DL6ER Jul 15, 2025
416468f
Remove unused code
DL6ER Jul 15, 2025
f1ddb23
Bind data attributes instead of inlining them for security (to preven…
DL6ER Jul 15, 2025
320c8d9
Allow "-" as separator for MAC addresses
DL6ER Jul 15, 2025
69b461a
Add IPv6 support for static DHCP lease management
DL6ER Jul 15, 2025
e7d7055
Add line numbers to dhcp.hosts textarea
DL6ER Jul 16, 2025
3489ddf
Validate hwaddr and IP address while typing
DL6ER Jul 20, 2025
9959791
Reject saving if there are still validation errors in the table
DL6ER Jul 20, 2025
7a90e56
Use background colors consistent with other tables on high contrast t…
rdwebdesign Jul 22, 2025
05595e3
Use non-transparent colors in the static DHCP table
DL6ER Jul 23, 2025
ce2a450
Better synchronize lines numbers and textarea
DL6ER Jul 24, 2025
e160fa6
Remove !important
DL6ER Jul 24, 2025
79c4c14
Merge development into new/simple-dhcp-static-leases and address revi…
DL6ER Mar 25, 2026
bd173da
Fix regex for hostname validation in DHCP settings
rdwebdesign Mar 27, 2026
a23462f
Add buttons to lines containing "advanced" settings
rdwebdesign Mar 27, 2026
02f143a
Make sure the .save-static-row button looks disabled when needed
rdwebdesign Mar 27, 2026
3501ad0
Merge branch 'development' into new/simple-dhcp-static-leases
rdwebdesign Mar 29, 2026
b3501d1
Fix prettier complaints
rdwebdesign Mar 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
323 changes: 314 additions & 9 deletions scripts/js/settings-dhcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,23 @@ $(() => {
},
rowCallback(row, data) {
$(row).attr("data-id", data.ip);
const button =
'<button type="button" class="btn btn-danger btn-xs" id="deleteLease_' +
data.ip +
'" data-del-ip="' +
data.ip +
'">' +
'<span class="far fa-trash-alt"></span>' +
"</button>";
$("td:eq(6)", row).html(button);
// Create buttons without data-* attributes in HTML
const $deleteBtn = $(
'<button type="button" class="btn btn-danger btn-xs"><span class="far fa-trash-alt"></span></button>'
)
.attr("id", "deleteLease_" + data.ip)
.attr("data-del-ip", data.ip)
.attr("title", "Delete lease")
.attr("data-toggle", "tooltip");
const $copyBtn = $(
'<button type="button" class="btn btn-secondary btn-xs copy-to-static"><i class="fa fa-fw fa-copy"></i></button>'
)
.attr("title", "Copy to static leases")
.attr("data-toggle", "tooltip")
.data("hwaddr", data.hwaddr || "")
Comment on lines +77 to +90
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Buttons are created dynamically with data-toggle="tooltip", but the tooltip initializer ($('[data-toggle="tooltip"]').tooltip(...)) only runs once on DOM ready. Newly inserted buttons (DataTables rows and StaticDHCPTable buttons) won't get Bootstrap tooltips unless tooltips are re-initialized after render/draw or initialized via delegated selector on body.

Copilot uses AI. Check for mistakes.
.data("ip", data.ip || "")
.data("hostname", data.name || "");
$("td:eq(6)", row).empty().append($deleteBtn, " ", $copyBtn);
},
select: {
style: "multi",
Expand Down Expand Up @@ -212,6 +220,8 @@ function delLease(ip) {

function fillDHCPhosts(data) {
$("#dhcp-hosts").val(data.value.join("\n"));
// Trigger input to update the table
$("#dhcp-hosts").trigger("input");
}

function processDHCPConfig() {
Expand All @@ -227,6 +237,301 @@ function processDHCPConfig() {
});
}

function parseStaticDHCPLine(line) {
// Accepts: [hwaddr][,ipaddr][,hostname] (all optional, comma-separated, no advanced tokens)
// Returns null if advanced/invalid, or {hwaddr, ipaddr, hostname}

// If the line is empty, return an object with empty fields
if (!line.trim())
return {
hwaddr: "",
ipaddr: "",
hostname: "",
};

// Advanced if contains id:, set:, tag:, ignore
if (/id:|set:|tag:|ignore|lease_time|,\s*,/v.test(line)) return "advanced";

// Split the line by commas and trim whitespace
const parts = line.split(",").map(s => s.trim());

// If there are more than 3 parts or less than 2, it's considered advanced
if (parts.length > 3 || parts.length < 2) return "advanced";

// Check if first part is a valid MAC address
const haveMAC = parts.length > 0 && utils.validateMAC(parts[0]);
const hwaddr = haveMAC ? parts[0].trim() : "";

// Check if the first or second part is a valid IPv4 or IPv6 address
const hasSquareBrackets0 = parts[0][0] === "[" && parts[0].at(-1) === "]";
const ipv60 = hasSquareBrackets0 ? parts[0].slice(1, -1) : parts[0];
const hasSquareBrackets1 = parts.length > 1 && parts[1][0] === "[" && parts[1].at(-1) === "]";
const ipv61 = hasSquareBrackets1 ? parts[1].slice(1, -1) : parts.length > 1 ? parts[1] : "";
const firstIsValidIP = utils.validateIPv4(parts[0]) || utils.validateIPv6(ipv60);
const secondIsValidIP =
parts.length > 1 && (utils.validateIPv4(parts[1]) || utils.validateIPv6(ipv61));
const ipaddr = firstIsValidIP ? parts[0].trim() : secondIsValidIP ? parts[1].trim() : "";
const haveIP = ipaddr.length > 0;

// Check if the second or third part is a valid hostname
let hostname = "";
if (parts.length > 2 && parts[2].length > 0) hostname = parts[2].trim();
else if (parts.length > 1 && parts[1].length > 0 && (!haveIP || !haveMAC))
hostname = parts[1].trim();
Comment on lines +256 to +280
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseStaticDHCPLine() can mis-parse valid dnsmasq lines like 00:11:22:33:44:55,laptop,infinite (MAC, hostname, lease_time). With parts.length === 3 and no IP present, the code treats the 3rd token as hostname, and saving from the table would drop the real hostname and corrupt the line. Consider only treating 3-part lines as editable when they match the unambiguous pattern MAC,IP,HOST (or otherwise classify them as "advanced" to keep them read-only).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a valid remark. --dhcp-host has only optional arguments so things can be quite fast 'advanced'
https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html


return {
hwaddr,
ipaddr,
hostname,
};
}

// Save button for each row updates only that line in the textarea, if it doesn't contain the class ".disabled"
$(document).on("click", ".save-static-row:not(.disabled)", function () {
const rowIdx = Number.parseInt($(this).data("row"), 10);
const row = $(this).closest("tr");
const hwaddr = row.find(".static-hwaddr").text().trim();
const ipaddr = row.find(".static-ipaddr").text().trim();
const hostname = row.find(".static-hostname").text().trim();

// Validate MAC and IP before saving
const macValid = !hwaddr || utils.validateMAC(hwaddr);
const ipValid = !ipaddr || utils.validateIPv4(ipaddr) || utils.validateIPv6(ipaddr);
if (!macValid || !ipValid) {
utils.showAlert(
"error",
"fa-times",
"Cannot save: Invalid MAC or IP address",
"Please correct the highlighted fields before saving."
);
return;
Comment on lines +297 to +307
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UI marks invalid hostnames with .table-danger, but the per-row save handler only validates MAC/IP and will still write an invalid hostname back into the textarea. Either validate hostname in the save path as well (blocking save when invalid), or avoid presenting the red/green validity state as an error if it won't be enforced.

Copilot uses AI. Check for mistakes.
}

const lines = $("#dhcp-hosts").val().split(/\r?\n/v);
// Only update if at least one field is non-empty
lines[rowIdx] =
hwaddr || ipaddr || hostname ? [hwaddr, ipaddr, hostname].filter(Boolean).join(",") : "";
$("#dhcp-hosts").val(lines.join("\n"));
// Optionally, re-render the table to reflect changes
renderStaticDHCPTable();
Comment on lines +310 to +316
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After updating #dhcp-hosts via the table actions (save/delete/add), the code sets .val(...) but does not trigger an input event. This leaves the new line-number gutter (and any other input listeners) out of sync because the line-number updater listens to the textarea's input event. Consider triggering input (or calling the line-number updater) after programmatic updates to the textarea.

Copilot uses AI. Check for mistakes.
});

// Delete button for each row removes that line from the textarea and updates the table
$(document).on("click", ".delete-static-row", function () {
const rowIdx = Number.parseInt($(this).data("row"), 10);
const lines = $("#dhcp-hosts").val().split(/\r?\n/v);
lines.splice(rowIdx, 1);
$("#dhcp-hosts").val(lines.join("\n"));
renderStaticDHCPTable();
});

// Add button for each row inserts a new empty line after this row
$(document).on("click", ".add-static-row", function () {
const rowIdx = Number.parseInt($(this).data("row"), 10);
const lines = $("#dhcp-hosts").val().split(/\r?\n/v);
lines.splice(rowIdx + 1, 0, "");
$("#dhcp-hosts").val(lines.join("\n"));
renderStaticDHCPTable();
// Focus the new row after render
setTimeout(() => {
$("#StaticDHCPTable tbody tr")
.eq(rowIdx + 1)
.find("td:first")
.focus();
}, 10);
});

// Update table on load and whenever textarea changes
$(() => {
processDHCPConfig();
renderStaticDHCPTable();
$("#dhcp-hosts").on("input", renderStaticDHCPTable);
});

// When editing a cell, disable all action buttons except the save button in the current row
$(document).on("focus input", "#StaticDHCPTable td[contenteditable]", function () {
const row = $(this).closest("tr");
// Disable all action buttons in all rows
$(
"#StaticDHCPTable .save-static-row, #StaticDHCPTable .delete-static-row, #StaticDHCPTable .add-static-row"
).prop("disabled", true);
// Enable only the save button in the current row
row.find(".save-static-row").prop("disabled", false);
// Show a hint below the current row if not already present
if (!row.next().hasClass("edit-hint-row")) {
row.next(".edit-hint-row").remove(); // Remove any existing hint
row.after(
'<tr class="edit-hint-row"><td colspan="4" class="text-info" style="font-style:italic;">Please confirm changes using the green button, then click "Save &amp; Apply" before leaving the page.</td></tr>'
);
}
});

// On save, re-enable all buttons (except buttons with class "disabled") and remove the hint
$(document).on("click", ".save-static-row", function () {
$(
"#StaticDHCPTable .save-static-row:not(.disabled), #StaticDHCPTable .delete-static-row, #StaticDHCPTable .add-static-row"
).prop("disabled", false);
$(".edit-hint-row").remove();
});

// On table redraw, ensure all buttons are enabled and hints are removed
function renderStaticDHCPTable() {
const tbody = $("#StaticDHCPTable tbody");
tbody.empty();
const lines = $("#dhcp-hosts").val().split(/\r?\n/v);
for (const [idx, line] of lines.entries()) {
const parsed = parseStaticDHCPLine(line);

const saveBtn = $(
'<button type="button" class="btn btn-success btn-xs save-static-row"><i class="fa fa-fw fa-check"></i></button>'
)
.attr("data-row", idx)
.attr("title", "Confirm changes to this line")
.attr("data-toggle", "tooltip");

const delBtn = $(
'<button type="button" class="btn btn-danger btn-xs delete-static-row"><i class="fa fa-fw fa-trash"></i></button>'
)
.attr("data-row", idx)
.attr("title", "Delete this line")
.attr("data-toggle", "tooltip");

const addBtn = $(
'<button type="button" class="btn btn-primary btn-xs add-static-row"><i class="fa fa-fw fa-plus"></i></button>'
)
.attr("data-row", idx)
.attr("title", "Add new line after this")
.attr("data-toggle", "tooltip");

const tr = $("<tr></tr>");

if (parsed === "advanced") {
tr.addClass("table-warning").append(
'<td colspan="3" class="text-muted"><em>Advanced settings present in line</em> ' +
(idx + 1) +
"</td>"
);

// Keep the original data
tr.data("original-line", line);

// Disable the save button on advanced rows
saveBtn.addClass("disabled").prop("disabled", true).attr("title", "Disabled");
} else {
// Append 3 cells containing parsed values, with placeholder for empty hwaddr
tr.append($('<td contenteditable="true" class="static-hwaddr"></td>').text(parsed.hwaddr))
.append($('<td contenteditable="true" class="static-ipaddr"></td>').text(parsed.ipaddr))
.append(
$('<td contenteditable="true" class="static-hostname"></td>').text(parsed.hostname)
);
}

// Append a last cell containing the buttons
tr.append($("<td></td>").append(saveBtn, " ", delBtn, " ", addBtn));

tbody.append(tr);
}

tbody.find(".save-static-row, .delete-static-row, .add-static-row").prop("disabled", false);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DL6ER

Why are you enabling all buttons here?
Are you sure this line is needed?

If the line is needed, we can enable the buttons, except Save buttons created with .disabled CSS class:

Suggested change
tbody.find(".save-static-row, .delete-static-row, .add-static-row").prop("disabled", false);
tbody.find(".save-static-row:not(.disabled), .delete-static-row, .add-static-row").prop("disabled", false);

Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renderStaticDHCPTable() re-enables all action buttons unconditionally. This overrides the earlier prop("disabled", true) on save buttons for "advanced" rows, making them clickable again (even though they still have the .disabled class). Update this to avoid re-enabling .save-static-row.disabled (or keep the disabled state set when rendering advanced rows) so advanced rows remain truly non-interactive.

Suggested change
tbody.find(".save-static-row, .delete-static-row, .add-static-row").prop("disabled", false);
tbody
.find(".save-static-row:not(.disabled), .delete-static-row, .add-static-row")
.prop("disabled", false);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

@rdwebdesign rdwebdesign Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change was already requested.

tbody.find(".edit-hint-row").remove();
}

// Copy button for each lease row copies the lease as a new static lease line
$(document).on("click", ".copy-to-static", function () {
const hwaddr = $(this).data("hwaddr") || "";
const ip = $(this).data("ip") || "";
const hostname = $(this).data("hostname") || "";
const line = [hwaddr, ip, hostname].filter(Boolean).join(",");
const textarea = $("#dhcp-hosts");
const val = textarea.val();
textarea.val(val ? val + "\n" + line : line).trigger("input");
});

// Add line numbers to the textarea for static DHCP hosts
document.addEventListener("DOMContentLoaded", function () {
const textarea = document.getElementById("dhcp-hosts");
const linesElem = document.getElementById("dhcp-hosts-lines");
let lastLineCount = 0;

function updateLineNumbers(force) {
if (!textarea || !linesElem) return;
const lines = textarea.value.split("\n").length || 1;
if (!force && lines === lastLineCount) return;
lastLineCount = lines;
let html = "";
for (let i = 1; i <= lines; i++) html += i + "<br>";
linesElem.innerHTML = html;
// Apply the same styles to the lines element as the textarea
for (const property of [
"fontFamily",
"fontSize",
"fontWeight",
"letterSpacing",
"lineHeight",
"padding",
"height",
]) {
linesElem.style[property] = globalThis.getComputedStyle(textarea)[property];
}

// Match height and scroll
linesElem.style.height = textarea.offsetHeight > 0 ? textarea.offsetHeight + "px" : "auto";
}

function syncScroll() {
linesElem.scrollTop = textarea.scrollTop;
}

if (textarea && linesElem) {
textarea.addEventListener("input", function () {
updateLineNumbers(false);
});
textarea.addEventListener("scroll", syncScroll);
window.addEventListener("resize", function () {
updateLineNumbers(true);
});
updateLineNumbers(true);
syncScroll();
}
});

$(document).on("input blur paste", "#StaticDHCPTable td.static-hwaddr", function () {
const val = $(this).text().trim();
if (val && !utils.validateMAC(val)) {
$(this).addClass("table-danger");
$(this).removeClass("table-success");
$(this).attr("title", "Invalid MAC address format");
} else {
$(this).addClass("table-success");
$(this).removeClass("table-danger");
$(this).attr("title", "");
}
});

$(document).on("input blur paste", "#StaticDHCPTable td.static-ipaddr", function () {
const val = $(this).text().trim();
if (val && !(utils.validateIPv4(val) || utils.validateIPv6(val))) {
$(this).addClass("table-danger");
$(this).removeClass("table-success");
$(this).attr("title", "Invalid IP address format");
} else {
$(this).addClass("table-success");
$(this).removeClass("table-danger");
$(this).attr("title", "");
}
Comment on lines +511 to +521
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IPv6 entries in dhcp.hosts are documented as requiring square brackets (e.g. [2001:db8::1]), but the table validation uses utils.validateIPv6(val) directly. Since validateIPv6() doesn't accept bracketed values, valid bracketed IPv6 addresses will be flagged invalid and blocked from saving. Adjust validation (and save-time checks) to accept bracketed IPv6 by stripping surrounding [] before calling validateIPv6*, and/or normalize stored values consistently.

Copilot uses AI. Check for mistakes.
});

$(document).on("input blur paste", "#StaticDHCPTable td.static-hostname", function () {
const val = $(this).text().trim();
// Hostnames must not contain spaces, commas, or characters invalid in DNS names
const hostnameValidator =
/^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*$/v;
if (val && !hostnameValidator.test(val)) {
$(this).addClass("table-danger");
$(this).removeClass("table-success");
$(this).attr("title", "Invalid hostname: only letters, digits, hyphens, and dots allowed");
} else {
$(this).removeClass("table-danger table-success");
$(this).attr("title", "");
}
});
24 changes: 21 additions & 3 deletions scripts/js/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,13 @@ function validateIPv4CIDR(ip) {
return ipv4validator.test(ip);
}

function validateIPv4(ip) {
// Add pseudo-CIDR to the IPv4
const ipv4WithCIDR = ip.includes("/") ? ip : ip + "/32";
// Validate the IPv4/CIDR
return validateIPv4CIDR(ipv4WithCIDR);
}

// Pi-hole IPv6/CIDR validator by DL6ER, see regexr.com/50csn
function validateIPv6CIDR(ip) {
// One IPv6 element is 16bit: 0000 - FFFF
Expand All @@ -249,14 +256,23 @@ function validateIPv6CIDR(ip) {
return ipv6validator.test(ip);
}

function validateIPv6(ip) {
// Add pseudo-CIDR to the IPv6
const ipv6WithCIDR = ip.includes("/") ? ip : ip + "/128";
// Validate the IPv6/CIDR
return validateIPv6CIDR(ipv6WithCIDR);
}

function validateMAC(mac) {
const macvalidator = /^([\da-fA-F]{2}:){5}([\da-fA-F]{2})$/v;
return macvalidator.test(mac);
// Format: xx:xx:xx:xx:xx:xx where each xx is 0-9 or a-f (case insensitive)
// Also allows dashes as separator, e.g. xx-xx-xx-xx-xx-xx
const macvalidator = /^([\da-f]{2}[:\-]){5}([\da-f]{2})$/iv;
return macvalidator.test(mac.trim());
}

function validateHostname(name) {
const namevalidator = /[^<>;"]/v;
return namevalidator.test(name);
return namevalidator.test(name.trim());
}

// set bootstrap-select defaults
Expand Down Expand Up @@ -709,7 +725,9 @@ globalThis.utils = (function () {
disableAll,
enableAll,
validateIPv4CIDR,
validateIPv4,
validateIPv6CIDR,
validateIPv6,
setBsSelectDefaults,
stateSaveCallback,
stateLoadCallback,
Expand Down
Loading
Loading