Improve DHCP static leases management GUI#3565
Conversation
…xtarea below Signed-off-by: DL6ER <dl6er@dl6er.de>
Signed-off-by: DL6ER <dl6er@dl6er.de>
Signed-off-by: DL6ER <dl6er@dl6er.de>
Signed-off-by: DL6ER <dl6er@dl6er.de>
…t any possibility for code injection) Signed-off-by: DL6ER <dl6er@dl6er.de>
|
The video looks good to me, but unfortunately I'm not able to test this as I have now stopped using PiHole (partly due to this very issue). However, two comments based on the video: |
Signed-off-by: DL6ER <dl6er@dl6er.de>
Yes, this is expected and per design. Advanced config lines may include
I did add |
Signed-off-by: DL6ER <dl6er@dl6er.de>
|
If something invalid is entered in the advanced box (e.g. The live-editing is cool but only shown advanced > table, not the other way round. Is this by design?
This could be hard to find if n is big. Could we show line numbers in the advanced text box? |
Signed-off-by: DL6ER <dl6er@dl6er.de>
|
I tested using an invalid IP ( When saved, the invalid IP is ignored, resulting in I think this is the correct behavior, but no warning was returned to the user, to inform something was wrong and the entry was "fixed" to fit the valid input fields. Maybe we need a warning/alert for cases like this. |
Signed-off-by: DL6ER <dl6er@dl6er.de>
|
Like so? Screencast.From.2025-07-20.1.webm |
Signed-off-by: DL6ER <dl6er@dl6er.de>
Screencast.From.2025-07-20.20-39-42.webm |
| #StaticDHCPTable td.table-danger { | ||
| background-color: #ff000022 !important; | ||
| color: #fff !important; | ||
| } | ||
|
|
||
| #StaticDHCPTable td.table-success { | ||
| background-color: #74c70022 !important; | ||
| color: #fff !important; | ||
| } |
There was a problem hiding this comment.
Quick suggestion just to improve the colors:
| #StaticDHCPTable td.table-danger { | |
| background-color: #ff000022 !important; | |
| color: #fff !important; | |
| } | |
| #StaticDHCPTable td.table-success { | |
| background-color: #74c70022 !important; | |
| color: #fff !important; | |
| } | |
| #StaticDHCPTable .table-danger { | |
| background-color: #d337; | |
| } | |
| #StaticDHCPTable .table-success { | |
| background-color: #2c24; | |
| } |
Still using semi-transparent colors, but now the contrast is good enough when using the original text color, so I think we don't need to change color:.
Also, we should use !important only in cases where we really need it.
There was a problem hiding this comment.
…hemes Signed-off-by: RD WebDesign <github@rdwebdesign.com.br>
|
I added one commit to change the high contrast themes. On these themes the colors are not as important as the contrast/readability. |
|
Note: I was testing with DHCP server disabled, so I'm not sure if we need to fix this, but when you use the textearea to enter a line, if there are invalid values on this line, the values are not checked and the API accepts everything, including the invalid values. The first line contains an invalid IP: |
Signed-off-by: DL6ER <dl6er@dl6er.de>
Signed-off-by: DL6ER <dl6er@dl6er.de>
Yes, invalid |
Signed-off-by: DL6ER <dl6er@dl6er.de>
|
Conflicts have been resolved. |
|
I've rebased this branch on If you would like to try this branch on docker, you can bake your own local image utilising this FTL branch by following these instructions: |
|
I have been running on this branch for last 2-3 days. |
|
This pull request has conflicts, please resolve those before we can evaluate the pull request. |
…ew feedback Resolve merge conflict in style/pi-hole.css (keep both StaticDHCPTable styles and DNSSEC query log styles). Address outstanding reviewer feedback: - Change save button icon from floppy-disk to checkmark to clarify it confirms the row edit, not a final save - Update hint text to mention "Save & Apply" is still needed - Add hostname validation on the hostname cell (rejects spaces, commas, and other characters invalid in DNS names) Signed-off-by: Dominik <dl6er@dl6er.de>
59c85c3 to
79c4c14
Compare
|
I rebased on current I also addressed the two outstanding review items:
|
When using the `v` flag, hyphens need to be escaped inside a character class. Signed-off-by: RD WebDesign <github@rdwebdesign.com.br>
|
This is the explanation for my commit: From developer.mozilla.org - Regular expressions - Character class: [...], [^...]: v-mode character class:
|
| tbody.append(tr); | ||
| } | ||
|
|
||
| tbody.find(".save-static-row, .delete-static-row, .add-static-row").prop("disabled", false); |
There was a problem hiding this comment.
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:
| 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); |
Signed-off-by: RD WebDesign <github@rdwebdesign.com.br>
Signed-off-by: RD WebDesign <github@rdwebdesign.com.br>
17be95d to
02f143a
Compare
Signed-off-by: RD WebDesign <github@rdwebdesign.com.br>
|
Conflicts have been resolved. |
0e48a16 to
13dedfe
Compare
Fix regex adding `v` flag and fix line breaks to respect maximum lenght Note: these issues were not previously reported because there was a merge conflict. The prettier test was only executed after the merge conflict was resolved. Signed-off-by: RD WebDesign <github@rdwebdesign.com.br>
13dedfe to
b3501d1
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 8 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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(); |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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
| $(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", ""); | ||
| } |
There was a problem hiding this comment.
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.
| 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(); |
There was a problem hiding this comment.
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.
| tbody.append(tr); | ||
| } | ||
|
|
||
| tbody.find(".save-static-row, .delete-static-row, .add-static-row").prop("disabled", false); |
There was a problem hiding this comment.
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.
| 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); |
| // 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 || "") |
There was a problem hiding this comment.
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.
| // 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; |
There was a problem hiding this comment.
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.
| #StaticDHCPTable .table-danger, | ||
| .blocked-row td { | ||
| background-color: rgba(230, 160, 160, 0.3); | ||
| } | ||
|
|
||
| #StaticDHCPTable .table-success, |
There was a problem hiding this comment.
The new theme overrides use #StaticDHCPTable .table-danger/.table-success, but base pi-hole.css defines #StaticDHCPTable td.table-danger/.table-success with higher specificity. That means the base colors will still win even when the high-contrast theme is loaded, so these theme rules likely won't take effect. Match the selector specificity (e.g. include td as in the base rule) and/or use !important so the theme can actually override the base styling.
| #StaticDHCPTable .table-danger, | |
| .blocked-row td { | |
| background-color: rgba(230, 160, 160, 0.3); | |
| } | |
| #StaticDHCPTable .table-success, | |
| #StaticDHCPTable td.table-danger, | |
| .blocked-row td { | |
| background-color: rgba(230, 160, 160, 0.3); | |
| } | |
| #StaticDHCPTable td.table-success, |
There was a problem hiding this comment.
This will be fixed if my previous suggestion to change pi-hole.css is used.
We don't need the higher specificity on pi-hole.css. A better fix would be to remove td from pi-hole.css, as suggested before.
| #StaticDHCPTable .table-danger, | ||
| .blocked-row td { | ||
| background-color: rgba(56, 0, 40, 0.35); | ||
| } | ||
|
|
||
| #StaticDHCPTable .table-success, | ||
| .allowed-row td { | ||
| background-color: rgba(0, 64, 64, 0.35); |
There was a problem hiding this comment.
These overrides use #StaticDHCPTable .table-danger/.table-success, but base pi-hole.css sets #StaticDHCPTable td.table-danger/.table-success (more specific). As a result, the base colors will override the theme and these rules may never apply. Increase specificity (e.g. target td.table-danger / td.table-success) and/or add !important so the high-contrast-dark theme can override the base table cell styling.
| #StaticDHCPTable .table-danger, | |
| .blocked-row td { | |
| background-color: rgba(56, 0, 40, 0.35); | |
| } | |
| #StaticDHCPTable .table-success, | |
| .allowed-row td { | |
| background-color: rgba(0, 64, 64, 0.35); | |
| #StaticDHCPTable td.table-danger, | |
| .blocked-row td { | |
| background-color: rgba(56, 0, 40, 0.35) !important; | |
| } | |
| #StaticDHCPTable td.table-success, | |
| .allowed-row td { | |
| background-color: rgba(0, 64, 64, 0.35) !important; |
There was a problem hiding this comment.
This will be fixed if my previous suggestion to change pi-hole.css is used.
We don't need the higher specificity on pi-hole.css. A better fix would be to remove td from pi-hole.css, as suggested before.
|
Hi all, was directed here based on issue #6593. Very encouraging to see the work on this. Have a couple suggestions, questions: a) Would it make sense to also mark which of the static leases are active? This is usually a convenient way of finding out if a specific device is online and has accepted the static lease already. This is something many router UIs offer. Other than that this looks very good and would v6 bring back to what v5 had already done nicely. |
Quick note: I can already tell you that a) is not really possible. Pi-hole can detect when a device renews its DHCP lease, but most of the time the DHCP lease time is one week or longer. When Pi-hole only sees a sign of life from the device every week or so, I dont think that it would result in useful data. Routers can track whether a device is online or not by looking at the IP packets it sends to the internet, since the router is the gateway. This allows much more accurate online tracking. For the vast majority, Pi-hole is not the gateway device, and even if it were, Pi-hole is restricted to DNS and DHCP and not routing. Regarding c), it could be possible, but I don't see it realistically being in Pi-hole. The goal of Pi-holes DHCP server has never been being a full-featured router. Like Dan Schaper (Pi-hole co-founder) said on the Discourse feature request:
b) is already possible with this PR (you can test it with |
|
I'm aware of the scope. Nevertheless the DHCP function is a core functionality as the correct DNS'es are also being broadcast by it and I, and I assume many others, have decided to use pihole for exactly that purpose - because it makes you independent of whatever your router/modem/gateway/whatever provider thinks is good for you. Happy to see b) addressed because that is actually most important. As for the lease activeness from a): Yes, that is kind of a heuristic. Probably also the least 'important' point. As for c): I think that has no bearing on whether pihole is a router or not, it's just a useful convenience feature. It does not require any router capabilities. |






What does this implement/fix?
See title. This adds a easy accessible table that is kept in sync with the
<textarea>which still allows for any advanced configuration.Please see this movie for a demonstration:
Screencast.From.2025-07-15.12-41-22.webm
You can test all of that even without having the DHCP server enabled. What will be saved in the end is always the content of said
<textarea>which is getting stores asdhcp.hostsinpihole.toml.Related issue or feature (if applicable): N/A
Pull request in docs with documentation (if applicable): N/A
By submitting this pull request, I confirm the following:
git rebase)Checklist:
developmentbranch.