Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
306 changes: 297 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 || "")
.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,284 @@ 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*,/.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();

return {
hwaddr,
ipaddr,
hostname,
};
}

// Save button for each row updates only that line in the textarea
$(document).on("click", ".save-static-row", 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;
}

const lines = $("#dhcp-hosts").val().split(/\r?\n/);
// 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();
});

// 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/);
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/);
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 save this line before editing another or leaving the page, otherwise your changes will be lost.</td></tr>'
);
}
});
// On save, re-enable all buttons and remove the hint
$(document).on("click", ".save-static-row", function () {
$(
"#StaticDHCPTable .save-static-row, #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/);
for (const [idx, line] of lines.entries()) {
const parsed = parseStaticDHCPLine(line);
if (parsed === "advanced") {
const tr = $(
'<tr class="table-warning"><td colspan="4" style="font-style:italic;color:#888;">Advanced settings present in line ' +
(idx + 1) +
"</td></tr>"
);
tr.data("original-line", line);
tbody.append(tr);
continue;
}

const tr = $("<tr>")
.append($('<td contenteditable="true" class="static-hwaddr"></td>'))
.append($('<td contenteditable="true" class="static-ipaddr"></td>'))
.append($('<td contenteditable="true" class="static-hostname"></td>'))
.append(
$("<td></td>")
.append(
$(
'<button type="button" class="btn btn-success btn-xs save-static-row"><i class="fa fa-fw fa-floppy-disk"></i></button>'
)
.attr("data-row", idx)
.attr("title", "Save changes to this line")
.attr("data-toggle", "tooltip")
)
.append(" ")
.append(
$(
'<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")
)
.append(" ")
.append(
$(
'<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")
)
);
// Set cell values, with placeholder for empty hwaddr
tr.find(".static-hwaddr").text(parsed.hwaddr);
tr.find(".static-ipaddr").text(parsed.ipaddr);
tr.find(".static-hostname").text(parsed.hostname);
tbody.append(tr);
}

tbody.find(".save-static-row, .delete-static-row, .add-static-row").prop("disabled", false);
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", "");
}
});
24 changes: 21 additions & 3 deletions scripts/js/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,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 @@ -216,14 +223,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})$/;
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})$/i;
return macvalidator.test(mac.trim());
}

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

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