/* Pi-hole: A black hole for Internet advertisements * (c) 2023 Pi-hole, LLC (https://pi-hole.net) * Network-wide ad blocking via your own hardware. * * This file is copyright under the latest version of the EUPL. * Please see LICENSE file for your rights under this license. */ /* global utils:false, setConfigValues: false, apiFailure: false */ "use strict"; let dhcpLeaesTable = null; const toasts = {}; // DHCP leases tooltips $("body").tooltip({ selector: '[data-toggle="tooltip"]', container: "body" }); function renderHostnameCLID(data, type) { // Display and search content if (type === "display" || type === "filter") { if (data === "*") { return "---"; } return data; } // Sorting content return data; } $(() => { dhcpLeaesTable = $("#DHCPLeasesTable").DataTable({ ajax: { url: document.body.dataset.apiurl + "/dhcp/leases", type: "GET", dataSrc: "leases", }, order: [[1, "asc"]], columns: [ { data: null, width: "22px" }, { data: "ip", type: "ip-address" }, { data: "name", render: renderHostnameCLID }, { data: "hwaddr" }, { data: "expires", render: utils.renderTimespan }, { data: "clientid", render: renderHostnameCLID }, { data: null, width: "22px", orderable: false }, ], columnDefs: [ { targets: 0, orderable: false, className: "select-checkbox", render() { return ""; }, }, { targets: "_all", render: $.fn.dataTable.render.text(), }, ], drawCallback() { $('button[id^="deleteLease_"]').on("click", deleteLease); // Hide buttons if all messages were deleted const hasRows = this.api().rows({ filter: "applied" }).data().length > 0; $(".datatable-bt").css("visibility", hasRows ? "visible" : "hidden"); // Remove visible dropdown to prevent orphaning $("body > .bootstrap-select.dropdown").remove(); }, rowCallback(row, data) { $(row).attr("data-id", data.ip); // Create buttons without data-* attributes in HTML const $deleteBtn = $( '' ) .attr("id", "deleteLease_" + data.ip) .attr("data-del-ip", data.ip) .attr("title", "Delete lease") .attr("data-toggle", "tooltip"); const $copyBtn = $( '' ) .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", selector: "td:not(:last-child)", info: false, }, buttons: [ { text: '', titleAttr: "Select All", className: "btn-sm datatable-bt selectAll", action() { dhcpLeaesTable.rows({ page: "current" }).select(); }, }, { text: '', titleAttr: "Select All", className: "btn-sm datatable-bt selectMore", action() { dhcpLeaesTable.rows({ page: "current" }).select(); }, }, { extend: "selectNone", text: '', titleAttr: "Deselect All", className: "btn-sm datatable-bt removeAll", }, { text: '', titleAttr: "Delete Selected", className: "btn-sm datatable-bt deleteSelected", action() { // For each ".selected" row ... $("tr.selected").each(function () { // ... delete the row identified by "data-id". delLease($(this).attr("data-id")); }); }, }, ], dom: "<'row'<'col-sm-6'l><'col-sm-6'f>>" + "<'row'<'col-sm-3'B><'col-sm-9'p>>" + "<'row'<'col-sm-12'<'table-responsive'tr>>>" + "<'row'<'col-sm-3'B><'col-sm-9'p>>" + "<'row'<'col-sm-12'i>>", lengthMenu: [ [10, 25, 50, 100, -1], [10, 25, 50, 100, "All"], ], language: { emptyTable: "No DHCP leases found.", }, stateSave: true, stateDuration: 0, stateSaveCallback(settings, data) { utils.stateSaveCallback("dhcp-leases-table", data); }, stateLoadCallback() { const data = utils.stateLoadCallback("dhcp-leases-table"); // Return if not available if (data === null) { return null; } // Apply loaded state to table return data; }, }); dhcpLeaesTable.on("init select deselect", () => { utils.changeTableButtonStates(dhcpLeaesTable); }); }); function deleteLease() { // Passes the button data-del-id attribute as IP delLease($(this).attr("data-del-ip")); } function delLease(ip) { utils.disableAll(); toasts[ip] = utils.showAlert("info", "", "Deleting lease...", ip, null); $.ajax({ url: document.body.dataset.apiurl + "/dhcp/leases/" + encodeURIComponent(ip), method: "DELETE", }) .done(response => { utils.enableAll(); if (response === undefined) { utils.showAlert( "success", "far fa-trash-alt", "Successfully deleted lease", ip, toasts[ip] ); dhcpLeaesTable.ajax.reload(null, false); } else { utils.showAlert( "error", "", "Error while deleting lease: " + ip, response.lease, toasts[ip] ); } // Clear selection after deletion dhcpLeaesTable.rows().deselect(); utils.changeTableButtonStates(dhcpLeaesTable); }) .fail((jqXHR, exception) => { utils.enableAll(); utils.showAlert( "error", "", "Error while deleting lease: " + ip, jqXHR.responseText, toasts[ip] ); console.log(exception); // eslint-disable-line no-console }); } function fillDHCPhosts(data) { $("#dhcp-hosts").val(data.value.join("\n")); // Trigger input to update the table $("#dhcp-hosts").trigger("input"); } function processDHCPConfig() { $.ajax({ url: document.body.dataset.apiurl + "/config/dhcp?detailed=true", }) .done(data => { fillDHCPhosts(data.config.dhcp.hosts); setConfigValues("dhcp", "dhcp", data.config.dhcp); }) .fail(data => { apiFailure(data); }); } 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 line contains id:, set:, tag, or "*": if (/id:|set:|tag:\*/u.test(line)) return "advanced"; // Split the line by commas and trim whitespace const parts = line.split(",").map(s => s.trim()); // Advanced if there are more than 3 parts if (parts.length > 3) return "advanced"; let hwaddr = ""; let ipaddr = ""; let hostname = ""; for (const part of parts) { // Advanced if the part is "infinite", "ignore" or a lease time value if (/^(infinite|ignore|\d+(w|W|d|D|h|H|m|M|s|S))$/u.test(part)) return "advanced"; if (part.includes(":")) { if (part.startsWith("[") && part.endsWith("]")) { // Advanced if more than one IP was found if (ipaddr) return "advanced"; // Potentially an IPv6 (we allow invalid values here. The table will highlight them) ipaddr = part; } else { // Advanced if more than one MAC Address was found if (hwaddr) return "advanced"; // Potentially a MAC Address (we allow invalid values here. The table will highlight them) hwaddr = part; } } else if (/^[.0-9]+$/u.test(part)) { // Advanced if more than one IP was found if (ipaddr) return "advanced"; // Potentially an IPv4 (we allow invalid values here. The table will highlight them) ipaddr = part; } else { // Advanced if more than one hostname was found if (hostname) return "advanced"; // Potentially a hostname (we allow invalid values here. The table will highlight them) hostname = part; } } 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 = $(`tr[data-row="${rowIdx}"]`); 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.validateIPv6Brackets(ipaddr); const nameValid = !hostname || utils.validateHostnameStrict(hostname); if (!macValid || !ipValid || !nameValid) { utils.showAlert( "error", "fa-times", "Cannot save: Invalid value found on the table", "Please correct the highlighted fields before saving." ); return; } const lines = $("#dhcp-hosts").val().split(/\r?\n/u); // 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")); // Update "data-original-line" to containing the new saved values row.attr("data-original-line", lines[rowIdx]); // Hide the tooltips and remove Save and Cancel buttons $(this).siblings(".cancel-static-row").tooltip("hide").remove(); $(this).tooltip("hide").remove(); // then remove highlight colors from all cells on this row $("td", row).blur(); // Check if all rows were already saved (no rows are still being edited) if ($("#StaticDHCPTable .save-static-row").length === 0) { // Re-enable all table buttons $("#StaticDHCPTable button").prop("disabled", false); // and re-enable the textarea $("#dhcp-hosts").prop("disabled", false); } }); // Cancel button: restores the original line value when editing a row $(document).on("click", ".cancel-static-row", function () { const rowIdx = Number.parseInt($(this).data("row"), 10); const row = $(`tr[data-row="${rowIdx}"]`); // Get the original values const originalLine = row.attr("data-original-line"); if (originalLine) { const values = parseStaticDHCPLine(originalLine); // Reset with original values, ensuring index exists row.find(".static-hwaddr").text(values.hwaddr); row.find(".static-ipaddr").text(values.ipaddr); row.find(".static-hostname").text(values.hostname); } else { // Optional: Handle empty state, e.g., clear fields or set defaults row.find(".static-hwaddr, .static-ipaddr, .static-hostname").text(""); } // Trigger "blur" event to remove highlight colors and titles from all cells on this row row.find(".static-hwaddr, .static-ipaddr, .static-hostname").blur(); // Then hide the tooltip and remove Save and Cancel buttons $(this).siblings(".save-static-row").tooltip("hide").remove(); $(this).tooltip("hide").remove(); // Check if all rows were already saved or canceled (no rows are still being edited) if ($("#StaticDHCPTable .save-static-row").length === 0) { // Re-enable all table buttons $("#StaticDHCPTable button").prop("disabled", false); // and re-enable the textarea $("#dhcp-hosts").prop("disabled", false); } }); // 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/u); lines.splice(rowIdx, 1); $("#dhcp-hosts").val(lines.join("\n")); // Hide the tooltip $(this).tooltip("hide"); 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/u); lines.splice(rowIdx + 1, 0, ""); $("#dhcp-hosts").val(lines.join("\n")); // Hide the tooltip $(this).tooltip("hide"); 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", function () { // Don't rebuild the table while a row is being edited (a Save button exists), // otherwise unsaved cell edits would be silently destroyed by tbody.empty() if ($("#StaticDHCPTable .save-static-row").length > 0) return; 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 .delete-static-row, #StaticDHCPTable .add-static-row").prop("disabled", true); // Add save button (a hint asking to click on the button will be shown below the table - CSS pseudo-element) if (row.find(".save-static-row").length === 0) { const idx = row.attr("data-row"); const saveBtn = $( '' ) .attr("data-row", idx) .attr("title", "Confirm changes to this line") .attr("data-toggle", "tooltip"); const cancelBtn = $( '' ) .attr("data-row", idx) .attr("title", "Cancel changes and restore original values") .attr("data-toggle", "tooltip"); // Add the save button to the actions column row.find("td").last().prepend(saveBtn, " ", cancelBtn, " "); // Disable the textarea to avoid losing unsaved changes to the table $("#dhcp-hosts").prop("disabled", true); } }); // 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/u); for (const [idx, line] of lines.entries()) { const parsed = parseStaticDHCPLine(line); const delBtn = $( '' ) .attr("data-row", idx) .attr("title", "Delete this line") .attr("data-toggle", "tooltip"); const addBtn = $( '' ) .attr("data-row", idx) .attr("title", "Add new line after this") .attr("data-toggle", "tooltip"); // Create the new row - store the original data, in case we need to restore the values const tr = $("