-
-
Notifications
You must be signed in to change notification settings - Fork 631
Improve DHCP static leases management GUI #3565
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: development
Are you sure you want to change the base?
Changes from all commits
fbed99b
2495a8e
937cd04
416468f
f1ddb23
320c8d9
69b461a
e7d7055
3489ddf
9959791
7a90e56
05595e3
ce2a450
e160fa6
79c4c14
bd173da
a23462f
02f143a
3501ad0
b3501d1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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", | ||||||||||||||
|
|
@@ -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() { | ||||||||||||||
|
|
@@ -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
|
||||||||||||||
|
|
||||||||||||||
| 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
|
||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| 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
|
||||||||||||||
| }); | ||||||||||||||
|
|
||||||||||||||
| // 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 & 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); | ||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why are you enabling all buttons here? If the line is needed, we can enable the buttons, except Save buttons created with
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); |
There was a problem hiding this comment.
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.
Copilot
AI
Mar 29, 2026
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 onbody.