mirror of
https://github.com/pi-hole/web.git
synced 2026-08-23 22:51:45 +01:00
Convert the remaining 20 .lp templates from AdminLTE 2's .box/.panel
component family to Bootstrap 5 .card markup, matching the pattern
established in index.lp/queries.lp during the layout migration:
box/box-header/box-title/box-body/box-footer -> card equivalents,
box-info/-danger/-warning etc. -> card-info/-danger/-warning +
card-outline for the non-solid variant, collapsed-box + data-widget
-> collapsed-card + data-lte-toggle="card-collapse" with the dual
expand/collapse icon AdminLTE 4 expects. Also renames the accompanying
Bootstrap-3-isms throughout (col-xs-*, pull-left/right, input-group-addon,
data-toggle/-dismiss/-backdrop/-keyboard, label -> badge, btn-default,
BS3 modal close-button markup).
Fix the JS-side selectors that referenced the old markup and would have
silently broken once the classes changed: charts.js's tooltip anchor
lookup, footer.js's "no visible cards" redirect check, login.js's
2FA/forgot-password card color swap (plus made both boxes consistently
non-solid so the swap doesn't need to touch card-outline), and
settings-advanced.js's dynamically generated settings cards and its
manual Bootstrap 3 "in"/"active" tab-pane class toggling (Bootstrap 5
renamed .in to .show). Rewrite settings-dhcp.js's jQuery-plugin tooltip
delegation (`$("body").tooltip(...)`, `.tooltip("hide")`) as a small
lazily-instantiating delegated listener plus direct bootstrap.Tooltip
calls, since Bootstrap 5 dropped jQuery integration entirely and the
DHCP lease action buttons are added to the DOM dynamically. Restore
Bootstrap 3's .has-error/.has-warning/.has-success form-validation
color feedback in pi-hole.css, since Bootstrap 5 dropped those classes
in favor of .is-invalid/.is-valid and a couple of pages still toggle
the old class names for simple color-coded feedback.
Refresh package-lock.json for the admin-lte/bootstrap version bump from
the previous commit.
Verified against a live container: dashboard, query log (incl. the
collapsible advanced-filter card and populated DataTable), groups/lists,
groups/domains, and settings/dns all render correctly with zero console
errors; card-collapse toggling confirmed working on the query log's
filter card. Plugin-styled elements (select2/bootstrap-select dropdowns,
DataTables chrome, icheck checkboxes, bootstrap-toggle switches, nav-tabs)
still look wrong until the JS-plugin-swap phase lands, as expected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Adam Warner <me@adamwarner.co.uk>
401 lines
12 KiB
JavaScript
401 lines
12 KiB
JavaScript
/* 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, apiFailure: false, applyCheckboxRadioStyle: false */
|
|
|
|
"use strict";
|
|
|
|
function addAllowedValues(allowed) {
|
|
if (typeof allowed === "object") {
|
|
return (
|
|
"<p>Available options:</p><ul><li>" +
|
|
allowed
|
|
.map(option => "<code>" + option.item + "</code>: " + utils.escapeHtml(option.description))
|
|
.join("</li><li>") +
|
|
"</li></ul>"
|
|
);
|
|
}
|
|
|
|
if (typeof allowed === "string") {
|
|
return `<p class="small">Allowed value: ${utils.escapeHtml(allowed)}</p>`;
|
|
}
|
|
}
|
|
|
|
function boxIcons(value) {
|
|
return (
|
|
'<span class="box-icons">' +
|
|
(value.modified
|
|
? '<i class="far fa-edit text-light-blue" title="Modified from default"></i>'
|
|
: "") +
|
|
(value.flags.restart_dnsmasq
|
|
? '<i class="fas fa-redo text-orange" title="Setting requires FTL restart on change"></i>'
|
|
: "") +
|
|
(value.flags.env_var
|
|
? '<i class="fas fa-lock text-orange" title="Settings overwritten by an environmental variable are read-only"></i>'
|
|
: "") +
|
|
"</span>"
|
|
);
|
|
}
|
|
|
|
function valueDetails(key, value) {
|
|
// Define default hint text
|
|
let defaultValueHint = "";
|
|
if (value.modified) {
|
|
defaultValueHint = "";
|
|
if (value.default !== null) {
|
|
let defVal = utils.escapeHtml(JSON.stringify(value.default));
|
|
switch (defVal) {
|
|
case "true": {
|
|
defVal = "enabled";
|
|
|
|
break;
|
|
}
|
|
|
|
case "false": {
|
|
defVal = "disabled";
|
|
|
|
break;
|
|
}
|
|
|
|
case '""':
|
|
case "[]": {
|
|
defVal = "empty";
|
|
|
|
break;
|
|
}
|
|
// No default
|
|
}
|
|
|
|
defaultValueHint = "<p>Default Value: " + defVal + "</p>";
|
|
}
|
|
}
|
|
|
|
// Define extraAttributes, if needed
|
|
const extraAttributes = value.flags.env_var ? " disabled" : "";
|
|
|
|
// Format the output depending on the value type
|
|
let content = "";
|
|
switch (value.type) {
|
|
case "IPv4 address":
|
|
case "IPv6 address":
|
|
case "string": {
|
|
content +=
|
|
'<label class="col-sm-2 control-label">Value <small>(string)</small></label>' +
|
|
'<div class="col-sm-10">' +
|
|
'<input type="text" class="form-control" value="' +
|
|
utils.escapeHtml(value.value) +
|
|
'" data-key="' +
|
|
utils.escapeHtml(key) +
|
|
'"' +
|
|
extraAttributes +
|
|
"> " +
|
|
defaultValueHint +
|
|
addAllowedValues(value.allowed) +
|
|
"</div>";
|
|
|
|
break;
|
|
}
|
|
|
|
case "boolean": {
|
|
content +=
|
|
'<div class="col-sm-12">' +
|
|
'<div><input type="checkbox" ' +
|
|
(value.value ? " checked" : "") +
|
|
' id="' +
|
|
key +
|
|
'-checkbox" data-key="' +
|
|
key +
|
|
'"' +
|
|
extraAttributes +
|
|
'><label for="' +
|
|
key +
|
|
'-checkbox">Enabled ' +
|
|
defaultValueHint +
|
|
"</label></div>" +
|
|
" </div>";
|
|
|
|
break;
|
|
}
|
|
|
|
case "double": {
|
|
content +=
|
|
'<label class="col-sm-2 control-label">Value</label>' +
|
|
'<div class="col-sm-10">' +
|
|
'<input type="number" class="form-control" value="' +
|
|
utils.escapeHtml(String(value.value)) +
|
|
'" data-key="' +
|
|
utils.escapeHtml(key) +
|
|
'" data-type="float"' +
|
|
extraAttributes +
|
|
"> " +
|
|
defaultValueHint +
|
|
"</div>";
|
|
|
|
break;
|
|
}
|
|
|
|
case "integer": {
|
|
content +=
|
|
'<label class="col-sm-2 control-label">Value <small>(integer)</small></label>' +
|
|
'<div class="col-sm-10">' +
|
|
'<input type="number" step="1" class="form-control" value="' +
|
|
utils.escapeHtml(String(value.value)) +
|
|
'" data-key="' +
|
|
utils.escapeHtml(key) +
|
|
'" data-type="integer"' +
|
|
extraAttributes +
|
|
"> " +
|
|
defaultValueHint +
|
|
"</div>";
|
|
|
|
break;
|
|
}
|
|
|
|
case "unsigned integer": {
|
|
content +=
|
|
'<label class="col-sm-4 control-label">Value <small>(unsigned integer)</small></label>' +
|
|
'<div class="col-sm-8">' +
|
|
'<input type="number" step="1" min="0" class="form-control" value="' +
|
|
utils.escapeHtml(String(value.value)) +
|
|
'" data-key="' +
|
|
utils.escapeHtml(key) +
|
|
'" data-type="integer"' +
|
|
extraAttributes +
|
|
"> " +
|
|
defaultValueHint +
|
|
"</div>";
|
|
|
|
break;
|
|
}
|
|
|
|
case "unsigned integer (16 bit)": {
|
|
content +=
|
|
'<label class="col-sm-4 control-label">Value <small>(unsigned 16bit integer)</small></label>' +
|
|
'<div class="col-sm-8">' +
|
|
'<input type="number" step="1" min="0" max="65535" class="form-control" value="' +
|
|
utils.escapeHtml(String(value.value)) +
|
|
'" data-key="' +
|
|
utils.escapeHtml(key) +
|
|
'" data-type="integer"' +
|
|
extraAttributes +
|
|
"> " +
|
|
defaultValueHint +
|
|
"</div>";
|
|
|
|
break;
|
|
}
|
|
|
|
case "string array": {
|
|
content +=
|
|
'<label class="col-sm-12 control-label">Values <small>(one item per line)</small></label>' +
|
|
'<div class="col-sm-12">' +
|
|
'<textarea class="form-control field-sizing-content" data-key="' +
|
|
key +
|
|
'"' +
|
|
extraAttributes +
|
|
">" +
|
|
value.value.join("\n") +
|
|
"</textarea> " +
|
|
defaultValueHint +
|
|
addAllowedValues(value.allowed) +
|
|
"</div>";
|
|
|
|
break;
|
|
}
|
|
|
|
case "enum (unsigned integer)": // fallthrough
|
|
case "enum (string)": {
|
|
content += '<div class="col-sm-12">';
|
|
for (const [i, option] of value.allowed.entries()) {
|
|
content +=
|
|
"<div>" +
|
|
// Radio button
|
|
'<input type="radio" class="form-control" ' +
|
|
`value="${option.item}" name="${key}" id="${key}_${i}" data-key="${key}"${extraAttributes}` +
|
|
(option.item === value.value ? " checked" : "") +
|
|
">" +
|
|
// Label
|
|
`<label for="${key}_${i}"><strong>${utils.escapeHtml(option.item)}` +
|
|
(option.item === value.default ? " <em>(default)</em>" : "") +
|
|
"</strong></label>" +
|
|
// Paragraph with description
|
|
`<p class="help-block">${option.description}</p>` +
|
|
"</div>";
|
|
}
|
|
|
|
content += "</div>";
|
|
|
|
break;
|
|
}
|
|
|
|
case "password (write-only string)": {
|
|
content +=
|
|
'<label class="col-sm-2 control-label">Value <small>(string)</small></label>' +
|
|
'<div class="col-sm-10">' +
|
|
'<input type="password" class="form-control" value="' +
|
|
value.value +
|
|
'" data-key="' +
|
|
key +
|
|
'"' +
|
|
extraAttributes +
|
|
"> " +
|
|
defaultValueHint +
|
|
addAllowedValues(value.allowed) +
|
|
"</div>";
|
|
|
|
break;
|
|
}
|
|
|
|
default: {
|
|
content += "TYPE " + value.type + " NOT DEFINED";
|
|
}
|
|
}
|
|
|
|
return '<div class="row">' + content + "</div>";
|
|
}
|
|
|
|
function generateRow(topic, key, value) {
|
|
// If the value is an object, we need to recurse
|
|
if (!("description" in value)) {
|
|
for (const [subkey, subvalue] of Object.entries(value)) {
|
|
generateRow(topic, key + "." + subkey, subvalue);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// else: we have a setting we can display
|
|
const box =
|
|
'<div class="card settings-box">' +
|
|
'<div class="card-header">' +
|
|
'<h3 class="card-title" data-key="' +
|
|
key +
|
|
'" data-modified="' +
|
|
(value.modified ? "true" : "false") +
|
|
'">' +
|
|
key +
|
|
boxIcons(value) +
|
|
"</h3>" +
|
|
"</div>" +
|
|
'<div class="card-body">' +
|
|
utils.escapeHtml(value.description).replaceAll("\n", "<br>") +
|
|
"</div>" +
|
|
'<div class="card-footer">' +
|
|
valueDetails(key, value) +
|
|
"</div></div> ";
|
|
|
|
const topKey = key.split(".", 1)[0];
|
|
const elem = $("#advanced-content-" + topKey + "-flex");
|
|
elem.append(box);
|
|
}
|
|
|
|
function createDynamicConfigTabs() {
|
|
$.ajax({
|
|
url: document.body.dataset.apiurl + "/config?detailed=true",
|
|
})
|
|
.done(data => {
|
|
// Create the tabs for the advanced dynamic config topics
|
|
for (const topic of Object.values(data.topics)) {
|
|
$("#advanced-settings-tabs").append(`
|
|
<div id="advanced-content-${topic.name}" role="tabpanel" class="tab-pane fade">
|
|
<h3 class="page-header">${topic.description} (<code>${topic.name}</code>)</h3>
|
|
<div class="row" id="advanced-content-${topic.name}-body">
|
|
<div class="col-12 settings-container" id="advanced-content-${topic.name}-flex"></div>
|
|
</div>
|
|
</div>
|
|
`);
|
|
|
|
// Dynamically create the settings menu
|
|
$("#advanced-settings-menu ul").append(`
|
|
<li role="presentation">
|
|
<a href="#advanced-content-${topic.name}" class="btn btn-secondary" aria-controls="advanced-content-${topic.name}" role="tab" data-bs-toggle="pill">${topic.description.replace(" settings", "")}</a>
|
|
</li>
|
|
`);
|
|
}
|
|
|
|
// Dynamically fill the tabs with config topics
|
|
for (const [topic, value] of Object.entries(data.config)) {
|
|
generateRow(topic, topic, value);
|
|
}
|
|
|
|
$("#advanced-overlay").hide();
|
|
|
|
// Select the first tab and show the content
|
|
$("#advanced-settings-menu ul li:first-child").addClass("active");
|
|
$("#advanced-settings-tabs > div:first-child").addClass("active show");
|
|
|
|
applyCheckboxRadioStyle();
|
|
applyOnlyChanged();
|
|
})
|
|
.fail(data => {
|
|
apiFailure(data);
|
|
});
|
|
}
|
|
|
|
function initOnlyChanged() {
|
|
const elem = $("#only-changed");
|
|
|
|
// Restore settings level from local storage (if available) or default to "false"
|
|
if (localStorage.getItem("only-changed") === null) {
|
|
localStorage.setItem("only-changed", "false");
|
|
}
|
|
|
|
elem.prop("checked", localStorage.getItem("only-changed") === "true");
|
|
|
|
elem.bootstrapToggle({
|
|
on: "Modified settings",
|
|
off: "All settings",
|
|
onstyle: "primary",
|
|
offstyle: "success",
|
|
size: "small",
|
|
width: "180px",
|
|
});
|
|
|
|
elem.on("change", function () {
|
|
localStorage.setItem("only-changed", $(this).prop("checked") ? "true" : "false");
|
|
applyOnlyChanged();
|
|
});
|
|
|
|
elem.bootstrapToggle(localStorage.getItem("only-changed") === "true" ? "on" : "off");
|
|
elem.trigger("change");
|
|
}
|
|
|
|
function applyOnlyChanged() {
|
|
if (localStorage.getItem("only-changed") === "true") {
|
|
// Show only modified settings (hide tabs menu and empty tabs).
|
|
|
|
// Hide the tabs menu
|
|
$("#advanced-settings-menu").hide();
|
|
|
|
// Show all tabs, except the ones containing "data-modified='true'" attribute
|
|
// to prevent empty tabs (using the same classes used by Boostrap3)
|
|
$("#advanced-settings-tabs > .tab-pane").addClass("show active");
|
|
$("#advanced-settings-tabs > .tab-pane:not(:has(h3[data-modified='true']))").removeClass(
|
|
"show active"
|
|
);
|
|
|
|
// Hide all boxes with data-key attribute, except the ones with "data-modified='true'" attribute
|
|
$(".card-title[data-key]").not("[data-modified='true']").closest(".card").hide();
|
|
} else {
|
|
// Show the tabs menu and activate only the first button (deactivate other buttons)
|
|
$("#advanced-settings-menu").show();
|
|
$("#advanced-settings-menu ul li").removeClass("active");
|
|
$("#advanced-settings-menu ul li:first-child").addClass("active");
|
|
|
|
// Hide all tabs, except the first one (removing the classes used by Boostrap3)
|
|
$("#advanced-settings-tabs > .tab-pane:not(:first-child)").removeClass("show active");
|
|
|
|
// Show all boxes with data-key attribute
|
|
$(".card-title[data-key]").closest(".card").show();
|
|
}
|
|
}
|
|
|
|
$(() => {
|
|
createDynamicConfigTabs();
|
|
initOnlyChanged();
|
|
});
|