diff --git a/scripts/js/settings-dns-records.js b/scripts/js/settings-dns-records.js index 68058b1c6e..518c3caba8 100644 --- a/scripts/js/settings-dns-records.js +++ b/scripts/js/settings-dns-records.js @@ -3,12 +3,17 @@ * Network-wide ad blocking via your own hardware. * * This file is copyright under the latest version of the EUPL. - * Precord see LICENSE file for your rights under this license. */ + * Please see LICENSE file for your rights under this license. */ /* global utils: false, apiFailure:false, setConfigValues: false */ "use strict"; +const disabledByEnv = { + hosts: false, + cnameRecords: false, +}; + function hostsDomain(data) { // Split record in format IP NAME1 [NAME2 [NAME3 [NAME...]]] // We split both on spaces and tabs to support both formats @@ -29,42 +34,307 @@ function hostsIP(data) { return ip; } -function CNAMEdomain(data) { +function cnameDomain(data) { // Split record in format ,[,] const CNAMEarr = data.split(","); return CNAMEarr[0].trim(); } -function CNAMEtarget(data) { +function cnameTarget(data) { // Split record in format ,[,] const CNAMEarr = data.split(","); return CNAMEarr[1].trim(); } -function CNAMEttl(data) { +function cnameTtl(data) { // Split record in format ,[,] const CNAMEarr = data.split(","); return CNAMEarr.length > 2 ? CNAMEarr[2] : "-"; } +function cnameTtlRaw(data) { + const CNAMEarr = data.split(","); + return CNAMEarr.length > 2 ? CNAMEarr[2].trim() : ""; +} + +function actionColumnIndex(endpoint) { + return endpoint === "hosts" ? 2 : 3; +} + +function editInputField(id, type, placeholder, value) { + return ( + '' + ); +} + +function createIconButton(styleClasses, options) { + const button = document.createElement("button"); + button.type = "button"; + if (options.id) { + button.id = options.id; + } + + button.classList.add("btn", "btn-xs", ...styleClasses); + + if (options.extraClasses) { + button.classList.add(...options.extraClasses); + } + + if (options.title) { + button.title = options.title; + } + + if (options.disabled) { + button.disabled = true; + } + + if (options.dataset) { + for (const [key, value] of Object.entries(options.dataset)) { + button.dataset[key] = value; + } + } + + const icon = document.createElement("span"); + icon.classList.add(...options.iconClasses); + button.append(icon); + return button; +} + +function appendEditActionButtons(cell, dataId) { + const saveButton = createIconButton(["btn-success"], { + extraClasses: ["btn-save-dns-record"], + title: "Save", + iconClasses: ["fa", "fa-check"], + dataset: { id: dataId }, + }); + const cancelButton = createIconButton(["btn-default"], { + extraClasses: ["btn-cancel-dns-record"], + title: "Cancel", + iconClasses: ["fa", "fa-xmark"], + }); + cell.append(saveButton, cancelButton); +} + +function buildEditRow(endpoint, data) { + const dataId = utils.hexEncode(data); + const editTr = document.createElement("tr"); + editTr.classList.add("dns-record-edit-row"); + + const appendCell = html => { + const td = document.createElement("td"); + td.innerHTML = html; + editTr.append(td); + }; + + if (endpoint === "hosts") { + appendCell( + editInputField( + "edit-host-domain-" + dataId, + "url", + "Domain", + utils.escapeHtml(hostsDomain(data)) + ) + ); + appendCell( + editInputField( + "edit-host-ip-" + dataId, + "text", + "Associated IP", + utils.escapeHtml(hostsIP(data)) + ) + ); + const actionCell = document.createElement("td"); + appendEditActionButtons(actionCell, dataId); + editTr.append(actionCell); + return $(editTr); + } + + appendCell( + editInputField( + "edit-cname-domain-" + dataId, + "url", + "Domain", + utils.escapeHtml(cnameDomain(data)) + ) + ); + appendCell( + editInputField( + "edit-cname-target-" + dataId, + "url", + "Target Domain", + utils.escapeHtml(cnameTarget(data)) + ) + ); + appendCell( + editInputField("edit-cname-ttl-" + dataId, "numeric", "", utils.escapeHtml(cnameTtlRaw(data))) + ); + const actionCell = document.createElement("td"); + appendEditActionButtons(actionCell, dataId); + editTr.append(actionCell); + return $(editTr); +} + +function closeAllEditRows(endpoint) { + $(`#${endpoint}-Table tbody tr.dns-record-edit-row`).remove(); + $(`#${endpoint}-Table tbody tr.shown`).removeClass("shown"); +} + +function closeEditRow(endpoint, dataTr) { + dataTr.next("tr.dns-record-edit-row").remove(); + dataTr.removeClass("shown"); +} + +function toggleEditRow(endpoint, button) { + const dataTr = $(button).closest("tr"); + const table = $(`#${endpoint}-Table`).DataTable(); + + if (dataTr.next("tr.dns-record-edit-row").length > 0) { + closeEditRow(endpoint, dataTr); + return; + } + + closeAllEditRows(endpoint); + buildEditRow(endpoint, table.row(dataTr).data()).insertAfter(dataTr); + dataTr.addClass("shown"); +} + +function buildHostsRecord(dataId) { + return ( + $("#edit-host-ip-" + dataId) + .val() + .trim() + + " " + + $("#edit-host-domain-" + dataId) + .val() + .trim() + ); +} + +function buildCnameRecord(dataId) { + let elem = + $("#edit-cname-domain-" + dataId) + .val() + .trim() + + "," + + $("#edit-cname-target-" + dataId) + .val() + .trim(); + const ttlVal = Number.parseInt($("#edit-cname-ttl-" + dataId).val(), 10); + if (Number.isFinite(ttlVal) && ttlVal >= 0) elem += "," + ttlVal; + return elem; +} + +function updateHostsRecord(oldTag, newTag, dataTr) { + if (oldTag === newTag) { + closeEditRow("hosts", dataTr); + return; + } + + utils.disableAll(); + utils.showAlert("info", "", "Updating DNS record...", newTag); + const baseUrl = document.body.dataset.apiurl + "/config/dns/hosts/"; + const deleteUrl = baseUrl + encodeURIComponent(oldTag); + const putUrl = baseUrl + encodeURIComponent(newTag); + + $.ajax({ + url: deleteUrl, + method: "DELETE", + }) + .then(() => + $.ajax({ + url: putUrl, + method: "PUT", + }) + ) + .done(() => { + utils.enableAll(); + utils.showAlert("success", "fas fa-pencil-alt", "Successfully updated DNS record", newTag); + $("#hosts-Table").DataTable().ajax.reload(null, false); + }) + .fail((data, exception) => { + utils.enableAll(); + apiFailure(data); + utils.showAlert( + "error", + "", + "Error while updating DNS record: " + utils.escapeHtml(oldTag) + "", + data.responseText + ); + console.log(exception); // eslint-disable-line no-console + }); +} + +function updateCnameRecord(oldTag, newTag, dataTr) { + if (oldTag === newTag) { + closeEditRow("cnameRecords", dataTr); + return; + } + + utils.disableAll(); + utils.showAlert("info", "", "Updating local CNAME record...", newTag); + const baseUrl = document.body.dataset.apiurl + "/config/dns/cnameRecords/"; + const deleteUrl = baseUrl + encodeURIComponent(oldTag); + const putUrl = baseUrl + encodeURIComponent(newTag); + + $.ajax({ + url: deleteUrl, + method: "DELETE", + }) + .then(() => + $.ajax({ + url: putUrl, + method: "PUT", + }) + ) + .done(() => { + utils.enableAll(); + utils.showAlert( + "success", + "fas fa-pencil-alt", + "Successfully updated local CNAME record", + newTag + ); + utils.loadingOverlay(true); + $("#cnameRecords-Table").DataTable().ajax.reload(null, false); + }) + .fail((data, exception) => { + utils.enableAll(); + apiFailure(data); + utils.showAlert( + "error", + "", + "Error while updating CNAME record: " + utils.escapeHtml(oldTag) + "", + data.responseText + ); + console.log(exception); // eslint-disable-line no-console + }); +} + function populateDataTable(endpoint) { let columns = ""; if (endpoint === "hosts") { columns = [ { data: null, render: hostsDomain }, { data: null, type: "ip-address", render: hostsIP }, - { data: null, width: "22px", orderable: false }, + { data: null, width: "70px", orderable: false }, ]; } else { columns = [ - { data: null, render: CNAMEdomain }, - { data: null, render: CNAMEtarget }, - { data: null, width: "40px", render: CNAMEttl }, - { data: null, width: "22px", orderable: false }, + { data: null, render: cnameDomain }, + { data: null, render: cnameTarget }, + { data: null, width: "40px", render: cnameTtl }, + { data: null, width: "70px", orderable: false }, ]; } - const setByEnv = false; $.ajax({ url: document.body.dataset.apiurl + "/config/dns/" + endpoint + "?detailed=true", }).done(data => { @@ -73,7 +343,9 @@ function populateDataTable(endpoint) { // disable input fields if set by env var if (data.config.dns[endpoint].flags.env_var) { + disabledByEnv[endpoint] = true; $(`.${endpoint}`).prop("disabled", true); + $(`#${endpoint}-Table`).DataTable().rows().invalidate().draw(false); } }); @@ -92,35 +364,31 @@ function populateDataTable(endpoint) { }, ], drawCallback() { - $(`button[id^="delete${endpoint}"]`).on("click", deleteRecord); - + closeAllEditRows(endpoint); // Remove visible dropdown to prevent orphaning $("body > .bootstrap-select.dropdown").remove(); }, rowCallback(row, data) { $(row).attr("data-id", data); - // Create delete button - const button = document.createElement("button"); - - // Set button ID and add CSS classes - button.id = `delete${endpoint}${utils.hexEncode(data)}`; - button.classList.add("btn", "btn-danger", "btn-xs"); - - // Set data-* attributes - button.dataset.type = endpoint; - button.dataset.tag = data; - - // Disable the button if set by environment variables - button.disabled = setByEnv; - - // Add a trash icon to the button - const iconSpan = document.createElement("span"); - iconSpan.classList.add("far", "fa-trash-alt"); - button.append(iconSpan); + const editButton = createIconButton(["btn-warning"], { + id: `edit${endpoint}${utils.hexEncode(data)}`, + title: "Edit record", + iconClasses: ["fas", "fa-pencil-alt"], + dataset: { action: "edit", type: endpoint }, + disabled: disabledByEnv[endpoint], + }); + const deleteButton = createIconButton(["btn-danger"], { + id: `delete${endpoint}${utils.hexEncode(data)}`, + title: "Delete record", + iconClasses: ["far", "fa-trash-alt"], + dataset: { action: "delete", type: endpoint, tag: data }, + disabled: disabledByEnv[endpoint], + }); - // Add the button to the table row - $(`td:eq(${endpoint === "hosts" ? 2 : 3})`, row).html(button); + $(`td:eq(${actionColumnIndex(endpoint)})`, row) + .empty() + .append(editButton, deleteButton); }, dom: "<'row'<'col-sm-5'l><'col-sm-7'f>>" + @@ -228,6 +496,39 @@ $(() => { populateDataTable("hosts"); populateDataTable("cnameRecords"); + for (const endpoint of ["hosts", "cnameRecords"]) { + const tableId = `#${endpoint}-Table`; + + $(`${tableId} tbody`).on("click", "button[data-action='edit']", function (event) { + event.stopPropagation(); + toggleEditRow(endpoint, this); + }); + + $(`${tableId} tbody`).on("click", "button[data-action='delete']", function (event) { + event.stopPropagation(); + deleteRecord.call(this); + }); + + $(tableId).on("click", ".btn-cancel-dns-record", function (event) { + event.stopPropagation(); + const dataTr = $(this).closest("tr.dns-record-edit-row").prev(); + closeEditRow(endpoint, dataTr); + }); + + $(tableId).on("click", ".btn-save-dns-record", function (event) { + event.stopPropagation(); + const dataId = $(this).attr("data-id"); + const dataTr = $(this).closest("tr.dns-record-edit-row").prev(); + const oldTag = dataTr.attr("data-id"); + + if (endpoint === "hosts") { + updateHostsRecord(oldTag, buildHostsRecord(dataId), dataTr); + } else { + updateCnameRecord(oldTag, buildCnameRecord(dataId), dataTr); + } + }); + } + $("#btnAdd-host").on("click", () => { utils.disableAll(); const elem = $("#Hip").val().trim() + " " + $("#Hdomain").val().trim(); @@ -256,9 +557,7 @@ $(() => { utils.disableAll(); let elem = $("#Cdomain").val().trim() + "," + $("#Ctarget").val().trim(); const ttlVal = Number.parseInt($("#Cttl").val(), 10); - // TODO Fix eslint - // eslint-disable-next-line unicorn/prefer-number-properties - if (isFinite(ttlVal) && ttlVal >= 0) elem += "," + ttlVal; + if (Number.isFinite(ttlVal) && ttlVal >= 0) elem += "," + ttlVal; const url = document.body.dataset.apiurl + "/config/dns/cnameRecords/" + encodeURIComponent(elem); utils.showAlert("info", "", "Adding DNS record...", elem); diff --git a/style/pi-hole.css b/style/pi-hole.css index ac8fdbec3a..9e8a94ddd1 100644 --- a/style/pi-hole.css +++ b/style/pi-hole.css @@ -412,6 +412,18 @@ tfoot.add-new-item > tr > th { vertical-align: inherit; } +/* Local DNS records: action buttons and inline edit row */ +#hosts-Table td .btn + .btn, +#cnameRecords-Table td .btn + .btn { + margin-left: 5px; +} + +#hosts-Table tr.dns-record-edit-row > td, +#cnameRecords-Table tr.dns-record-edit-row > td { + font-weight: normal; + vertical-align: inherit; +} + .select2-container--default .select2-results > .select2-results__options { max-height: 400px; }