Pi-hole Web v6.3 (#3594)

This commit is contained in:
Adam Warner
2025-10-25 11:12:17 +01:00
committed by GitHub
49 changed files with 3263 additions and 3306 deletions

View File

@@ -26,21 +26,21 @@ jobs:
steps:
- name: Clone repository
uses: actions/checkout@v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@v3.28.18
uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 #v4.30.9
with:
config-file: ./.github/codeql/codeql-config.yml
languages: "javascript"
queries: +security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v3.28.18
uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 #v4.30.9
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.28.18
uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 #v4.30.9
with:
category: "/language:javascript"

View File

@@ -13,12 +13,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Clone repository
uses: actions/checkout@v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
with:
persist-credentials: false
- name: Spell-Checking
uses: codespell-project/actions-codespell@master
uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 #v2.1
with:
ignore_words_file: .codespellignore
skip: ./vendor,./package.json,./package-lock.json

View File

@@ -10,8 +10,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Clone repository
uses: actions/checkout@v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
with:
persist-credentials: false
- uses: editorconfig-checker/action-editorconfig-checker@main
- uses: editorconfig-checker/action-editorconfig-checker@5ecdd656fe347c26f76b1b435b90e1d74fb5e787 # tag v2. is really out of date
- run: editorconfig-checker

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check if PRs are have merge conflicts
uses: eps1lon/actions-label-merge-conflict@v3.0.3
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 #v3.0.3
with:
dirtyLabel: "Merge Conflicts"
repoToken: "${{ secrets.GITHUB_TOKEN }}"

View File

@@ -17,7 +17,7 @@ jobs:
issues: write
steps:
- uses: actions/stale@v9.1.0
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 #v10.1.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 30
@@ -41,7 +41,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Clone repository
uses: actions/checkout@v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
with:
persist-credentials: false
- name: Remove 'stale' label

View File

@@ -16,7 +16,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@v9.1.0
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 #v10.1.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
# Do not automatically mark PR/issue as stale

View File

@@ -11,7 +11,7 @@ jobs:
name: Syncing branches
steps:
- name: Clone repository
uses: actions/checkout@v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
with:
persist-credentials: false
- name: Opening pull request

View File

@@ -19,12 +19,12 @@ jobs:
steps:
- name: Clone repository
uses: actions/checkout@v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@v4.4.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 #v6.0.0
with:
node-version: "22.x"
cache: npm

View File

@@ -9,14 +9,15 @@
mg.include('scripts/lua/header.lp','r')
?>
<body class="hold-transition layout-boxed login-page page-<?=pihole.format_path(mg.request_info.request_uri)?>">
</head>
<body class="hold-transition layout-boxed login-page page-<?=pihole.format_path(scriptname)?>">
<div class="box login-box">
<section style="padding: 15px;">
<h2 class="error-headline text-danger">403</h2>
<div class="error-content">
<h3><i class="fa fa-times-circle text-danger"></i> Oops! Access denied.</h3>
<p>
You don't have permission to access <code><?=mg.request_info.request_uri?></code> on this server.<br>
You don't have permission to access this URL.<br>
Did you mean to go to <a href="<?=pihole.webhome()?>">your Pi-hole's dashboard</a> instead?
</p>
</div>

View File

@@ -9,7 +9,8 @@
mg.include('scripts/lua/header.lp','r')
?>
<body class="hold-transition layout-boxed login-page page-<?=pihole.format_path(mg.request_info.request_uri)?>">
</head>
<body class="hold-transition layout-boxed login-page page-<?=pihole.format_path(scriptname)?>">
<div class="box login-box">
<section style="padding: 15px;">
<h2 class="error-headline text-yellow">404</h2>

View File

@@ -15,17 +15,17 @@ mg.include('scripts/lua/header_authenticated.lp','r')
</div>
<!-- Alerts -->
<div id="alInfo" class="alert alert-info alert-dismissible fade in" role="alert" hidden>
<div id="alertInfo" class="alert alert-info alert-dismissible fade in" role="alert" hidden>
<button type="button" class="close" data-hide="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
Updating... this may take a while. <strong>Please do not navigate away from or close this page.</strong>
</div>
<div id="alSuccess" class="alert alert-success alert-dismissible fade in" role="alert" hidden>
<div id="alertSuccess" class="alert alert-success alert-dismissible fade in" role="alert" hidden>
<button type="button" class="close" data-hide="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
Success!
</div>
<button type="button" id="gravityBtn" class="btn btn-lg btn-primary btn-block">Update</button>
<pre id="output" style="width: 100%; height: 100%;" hidden></pre>
<pre id="output" class="d-none"></pre>
<script src="<?=pihole.fileversion('scripts/js/gravity.js')?>"></script>
<? mg.include('scripts/lua/footer.lp','r')?>

View File

@@ -46,16 +46,16 @@ mg.include('scripts/lua/header_authenticated.lp','r')
<div class="row">
<div class="col-md-12">
<p>You can select an existing client or add a custom one by typing into the field above and confirming your entry with <kbd>&#x23CE;</kbd>.
Multiple clients can be added by separating each client with a space or comma.</p>
<p>Clients may be described either by their IP addresses (IPv4 and IPv6 are supported),
IP subnets (CIDR notation, like <code>192.168.2.0/24</code>),
their MAC addresses (like <code>12:34:56:78:9A:BC</code>),
by their hostnames (like <code>localhost</code>), or by the interface they are connected to (prefaced with a colon, like <code>:eth0</code>).
</p>
<p>Note that client recognition by IP addresses (incl. subnet ranges) are preferred over MAC address, host name or interface recognition as
the two latter will only be available after some time.
Furthermore, MAC address recognition only works for devices at most one networking hop away from your Pi-hole.
</p>
Multiple clients can be added by separating each client with a space or comma. Clients may be described either by their</p>
<ol>
<li>IP addresses (IPv4 and IPv6 are supported),</li>
<li>IP subnets (CIDR notation, like <code>192.168.2.0/24</code>), their</li>
<li>MAC addresses (like <code>12:34:56:78:9A:BC</code>), by their</li>
<li>hostnames (like <code>localhost</code>), or by the</li>
<li>interface they are connected to (prefaced with a colon, like <code>:eth0</code>).</li>
</ol>
<p>The first match (from top to down) wins. Note that client recognition by host name or interface recognition as
the two latter may only be available after some time. Furthermore, MAC address recognition only works for devices at most one networking hop away from your Pi-hole.</p>
</div>
</div>
</div>

View File

@@ -100,10 +100,10 @@ mg.include('scripts/lua/header_authenticated.lp','r')
</div>
<div class="btn-toolbar pull-right" role="toolbar" aria-label="Toolbar with buttons">
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary" id="add_deny">Add to denied domains</button>
<button type="button" class="btn btn-danger" id="add_deny">Add to denied domains</button>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary" id="add_allow">Add to allowed domains</button>
<button type="button" class="btn btn-success" id="add_allow">Add to allowed domains</button>
</div>
</div>
</div>

View File

@@ -9,7 +9,8 @@
mg.include('scripts/lua/header.lp','r')
?>
<body class="hold-transition layout-boxed login-page page-<?=pihole.format_path(mg.request_info.request_uri)?>" data-apiurl="<?=pihole.api_url()?>" data-webhome="<?=webhome?>">
</head>
<body class="hold-transition layout-boxed login-page page-<?=pihole.format_path(scriptname)?>" data-apiurl="<?=pihole.api_url()?>" data-webhome="<?=webhome?>">
<div class="box login-box" id="login-box">
<section style="padding: 15px;">
<div class="login-logo">
@@ -50,9 +51,13 @@ mg.include('scripts/lua/header.lp','r')
</span>
</div>
</div>
<div class="form-group has-feedback hidden" id="totp_input">
<input type="text" id="totp" size="6" maxlen="6" class="form-control totp_token" placeholder="123456" value="" spellcheck="false" autofocus autocomplete="off">
<span class="fa fa-clock-rotate-left pwd-field form-control-feedback"></span>
<div class="form-group hidden" id="totp_input">
<div class="input-group">
<input type="text" id="totp" size="6" maxlen="6" class="form-control totp_token" placeholder="123456" value="" spellcheck="false" autofocus autocomplete="off">
<div class="input-group-addon" data-toggle="tooltip" data-placement="auto" title="TOTP verification code">
<i class="fa fa-clock-rotate-left"></i>
</div>
</div>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary form-control"><i class="fas fa-sign-in-alt"></i>&nbsp;&nbsp;&nbsp;Log in (uses cookie)</button>

3983
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,7 +33,7 @@
"bootstrap-select": "1.13.18",
"bootstrap-toggle": "2.2.2",
"bstreeview": "1.2.0",
"chart.js": "4.4.9",
"chart.js": "4.5.0",
"chartjs-adapter-moment": "1.0.1",
"chartjs-plugin-deferred": "2.0.0",
"chartjs-plugin-zoom": "2.2.0",
@@ -53,11 +53,13 @@
},
"devDependencies": {
"autoprefixer": "^10.4.21",
"eslint": "^9.38.0",
"eslint-plugin-compat": "^6.0.2",
"postcss": "^8.5.3",
"globals": "^16.4.0",
"postcss": "^8.5.6",
"postcss-cli": "^11.0.1",
"prettier": "^3.5.3",
"xo": "^0.60.0"
"prettier": "^3.6.2",
"xo": "^1.2.3"
},
"browserslist": [
">= 0.5%",
@@ -73,50 +75,5 @@
"printWidth": 100,
"singleQuote": false,
"trailingComma": "es5"
},
"xo": {
"parserOptions": {
"sourceType": "script"
},
"envs": [
"browser",
"jquery"
],
"extends": [
"plugin:compat/recommended"
],
"prettier": true,
"space": 2,
"ignores": [
"**/vendor/**"
],
"rules": {
"camelcase": [
"error",
{
"properties": "never"
}
],
"capitalized-comments": "off",
"new-cap": [
"error",
{
"properties": false
}
],
"no-alert": "off",
"no-console": "error",
"prefer-arrow-callback": "error",
"spaced-comment": "off",
"strict": "error",
"unicorn/no-anonymous-default-export": "off",
"unicorn/no-document-cookie": "off",
"unicorn/no-negated-condition": "off",
"unicorn/prefer-module": "off",
"unicorn/prefer-query-selector": "off",
"unicorn/prefer-string-slice": "off",
"unicorn/prevent-abbreviations": "off",
"unicorn/switch-case-braces": "off"
}
}
}

View File

@@ -138,7 +138,7 @@ mg.include('scripts/lua/header_authenticated.lp','r')
</div>
<!-- /.box-body -->
<div class="box-footer clearfix">
<span class="pull-left">* These input fields allow manual input as well. Use <code>*</code> for wildcard search.</span>
<span class="pull-left">* These input fields allow manual input as well. Use <code>*</code> for wildcard search. An <code>_</code> can be used as a single-character wildcard. If you want to search for <code>_</code> explicitly, then you must escape it like <code>\_</code></span>
<span class="pull-right">Click "Refresh" below to apply.</span>
</div>
<!-- /.box -->

View File

@@ -5,12 +5,11 @@
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
/* global upstreams:false */
/* global upstreamIPs:false */
"use strict";
// eslint-disable-next-line no-unused-vars
const THEME_COLORS = [
globalThis.THEME_COLORS = [
"#f56954",
"#3c8dbc",
"#00a65a",
@@ -29,24 +28,24 @@ const THEME_COLORS = [
"#d2d6de",
];
// eslint-disable-next-line no-unused-vars
const htmlLegendPlugin = {
globalThis.htmlLegendPlugin = {
id: "htmlLegend",
afterUpdate(chart, args, options) {
const ul = getOrCreateLegendList(chart, options.containerID);
// Use the built-in legendItems generator
const items = chart.options.plugins.legend.labels.generateLabels(chart);
// Exit early if the legend has the same items as last time
if (
const isLegendUnchanged =
options.lastLegendItems &&
items.length === options.lastLegendItems.length &&
items.every((item, i) => item.text === options.lastLegendItems[i].text) &&
items.every((item, i) => item.hidden === options.lastLegendItems[i].hidden)
) {
return;
}
items.every(
(item, i) =>
item.text === options.lastLegendItems[i].text &&
item.hidden === options.lastLegendItems[i].hidden
);
if (isLegendUnchanged) return;
// else: Update the HTML legend if it is different from last time or if it
// did not exist
@@ -54,6 +53,8 @@ const htmlLegendPlugin = {
// need to update the legend
options.lastLegendItems = items;
const ul = getOrCreateLegendList(options.containerID);
// Remove old legend items
while (ul.firstChild) {
ul.firstChild.remove();
@@ -61,89 +62,89 @@ const htmlLegendPlugin = {
for (const item of items) {
const li = document.createElement("li");
li.style.alignItems = "center";
li.style.cursor = "pointer";
li.style.display = "flex";
li.style.flexDirection = "row";
// Select the corresponding "slice" of the chart when the mouse is over a legend item
li.addEventListener("mouseover", () => {
chart.setActiveElements([
{
datasetIndex: 0,
index: item.index,
},
]);
chart.update();
});
// Deselect all "slices"
li.addEventListener("mouseout", () => {
chart.setActiveElements([]);
chart.update();
});
// Color checkbox (toggle visibility)
const boxSpan = document.createElement("span");
boxSpan.title = "Toggle visibility";
boxSpan.style.color = item.fillStyle;
boxSpan.style.display = "inline-block";
boxSpan.style.margin = "0 10px";
boxSpan.innerHTML = item.hidden
? '<i class="colorBoxWrapper fa fa-square"></i>'
: '<i class="colorBoxWrapper fa fa-check-square"></i>';
boxSpan.style.cursor = "pointer";
boxSpan.innerHTML = `<i class="colorBoxWrapper fa ${item.hidden ? "fa-square" : "fa-check-square"}"></i>`;
boxSpan.addEventListener("click", () => {
const { type } = chart.config;
const isPieOrDoughnut = type === "pie" || type === "doughnut";
if (type === "pie" || type === "doughnut") {
if (isPieOrDoughnut) {
// Pie and doughnut charts only have a single dataset and visibility is per item
chart.toggleDataVisibility(item.index);
} else {
chart.setDatasetVisibility(item.datasetIndex, !chart.isDatasetVisible(item.datasetIndex));
const isVisible = chart.isDatasetVisible(item.datasetIndex);
chart.setDatasetVisibility(item.datasetIndex, !isVisible);
}
chart.update();
});
const textLink = document.createElement("p");
if (
chart.canvas.id === "queryTypePieChart" ||
chart.canvas.id === "forwardDestinationPieChart"
) {
// Text (link to query log page)
textLink.title = "List " + item.text + " queries";
const link = document.createElement("a");
const isQueryTypeChart = chart.canvas.id === "queryTypePieChart";
const isForwardDestinationChart = chart.canvas.id === "forwardDestinationPieChart";
textLink.addEventListener("click", () => {
if (chart.canvas.id === "queryTypePieChart") {
globalThis.location.href = "queries?type=" + item.text;
} else if (chart.canvas.id === "forwardDestinationPieChart") {
// Encode the forward destination as it may contain an "#" character
const upstream = encodeURIComponent(upstreams[item.text]);
globalThis.location.href = "queries?upstream=" + upstream;
if (isQueryTypeChart || isForwardDestinationChart) {
// Text (link to query log page)
link.title = `List ${item.text} queries`;
link.className = "legend-label-text clickable";
if (isQueryTypeChart) {
link.href = `queries?type=${item.text}`;
} else {
// Encode the forward destination as it may contain an "#" character
link.href = `queries?upstream=${encodeURIComponent(upstreamIPs[item.index])}`;
// If server name and IP are different:
if (item.text !== upstreamIPs[item.index]) {
// replace the title tooltip to include the upstream IP to the text ...
link.title = `List ${item.text} (${upstreamIPs[item.index]}) queries`;
// ... and include the server name (without port) to the querystring, to match
// the text used on the SELECT element (sent by suggestions API endpoint)
link.href += ` (${item.text.split("#")[0]})`;
}
});
}
} else {
// no clickable links in other charts
link.className = "legend-label-text";
}
textLink.style.margin = 0;
textLink.style.padding = 0;
textLink.style.textDecoration = item.hidden ? "line-through" : "";
textLink.className = "legend-label-text";
textLink.textContent = item.text;
link.style.textDecoration = item.hidden ? "line-through" : "";
link.textContent = item.text;
li.append(boxSpan, textLink);
li.append(boxSpan, link);
ul.append(li);
}
},
};
// eslint-disable-next-line no-unused-vars
const customTooltips = function (context) {
globalThis.customTooltips = context => {
const tooltip = context.tooltip;
let tooltipEl = document.getElementById(this.chart.canvas.id + "-customTooltip");
if (!tooltipEl) {
// Create Tooltip Element once per chart
tooltipEl = document.createElement("div");
tooltipEl.id = this.chart.canvas.id + "-customTooltip";
tooltipEl.classList.add("chartjs-tooltip");
tooltipEl.innerHTML = "<div class='arrow'></div> <table></table>";
// avoid browser's font-zoom since we know that <body>'s
// font-size was set to 14px by bootstrap's css
const fontZoom = Number.parseFloat($("body").css("font-size")) / 14;
// set styles and font
tooltipEl.style.padding = tooltip.options.padding + "px " + tooltip.options.padding + "px";
tooltipEl.style.borderRadius = tooltip.options.cornerRadius + "px";
tooltipEl.style.font = tooltip.options.bodyFont.string;
tooltipEl.style.fontFamily = tooltip.options.bodyFont.family;
tooltipEl.style.fontSize = tooltip.options.bodyFont.size / fontZoom + "px";
tooltipEl.style.fontStyle = tooltip.options.bodyFont.style;
// append Tooltip next to canvas-containing box
tooltipEl.ancestor = this.chart.canvas.closest(".box[id]").parentNode;
tooltipEl.ancestor.append(tooltipEl);
}
const canvasId = context.chart.canvas.id;
const tooltipEl = getOrCreateTooltipElement(canvasId, tooltip.options, context);
// Hide if no tooltip
if (tooltip.opacity === 0) {
@@ -152,169 +153,231 @@ const customTooltips = function (context) {
}
// Set caret position
tooltipEl.classList.remove("left", "right", "center", "top", "bottom");
tooltipEl.classList.add(tooltip.xAlign, tooltip.yAlign);
setTooltipCaretPosition(tooltipEl, tooltip);
// Set Text
// Set tooltip content
if (tooltip.body) {
const titleLines = tooltip.title || [];
const bodyLines = tooltip.body.map(bodyItem => bodyItem.lines);
let innerHtml = "<thead>";
for (const title of titleLines) {
innerHtml += "<tr><th>" + title + "</th></tr>";
}
innerHtml += "</thead><tbody>";
let printed = 0;
const devicePixel = (1 / window.devicePixelRatio).toFixed(1);
for (const [i, body] of bodyLines.entries()) {
const labelColors = tooltip.labelColors[i];
let style = "background-color: " + labelColors.backgroundColor;
style += "; outline: 1px solid " + labelColors.backgroundColor;
style += "; border: " + devicePixel + "px solid #fff";
const span = "<span class='chartjs-tooltip-key' style='" + style + "'></span>";
const num = body[0].split(": ");
// do not display entries with value of 0 (in bar chart),
// but pass through entries with "0.0% (in pie charts)
if (num[1] !== "0") {
innerHtml += "<tr><td>" + span + body + "</td></tr>";
printed++;
}
}
if (printed < 1) {
innerHtml += "<tr><td>No activity recorded</td></tr>";
}
innerHtml += "</tbody>";
const tableRoot = tooltipEl.querySelector("table");
tableRoot.innerHTML = innerHtml;
setTooltipContent(tooltipEl, tooltip);
}
const canvasPos = this.chart.canvas.getBoundingClientRect();
// Position tooltip
positionTooltip(tooltipEl, tooltip, context);
// Make tooltip visible
tooltipEl.style.opacity = 1;
};
function getOrCreateTooltipElement(canvasId, options, context) {
let tooltipEl = document.getElementById(`${canvasId}-customTooltip`);
if (tooltipEl) return tooltipEl;
// Create Tooltip Element once per chart
tooltipEl = document.createElement("div");
tooltipEl.id = `${canvasId}-customTooltip`;
tooltipEl.className = "chartjs-tooltip";
tooltipEl.innerHTML = '<div class="arrow"></div> <table></table>';
// Avoid browser's font-zoom since we know that <body>'s
// font-size was set to 14px by Bootstrap's CSS
const fontZoom = Number.parseFloat(getComputedStyle(document.body).fontSize) / 14;
// Set styles and font
tooltipEl.style.cssText = `
padding: ${options.padding}px ${options.padding}px;
border-radius: ${options.cornerRadius}px;
font: ${options.bodyFont.string};
font-family: ${options.bodyFont.family};
font-size: ${options.bodyFont.size / fontZoom}px;
font-style: ${options.bodyFont.style};
`;
// Append Tooltip next to canvas-containing box
tooltipEl.ancestor = context.chart.canvas.closest(".box[id]").parentNode;
tooltipEl.ancestor.append(tooltipEl);
return tooltipEl;
}
function setTooltipCaretPosition(tooltipEl, tooltip) {
tooltipEl.classList.remove("left", "right", "center", "top", "bottom");
tooltipEl.classList.add(tooltip.xAlign, tooltip.yAlign);
}
function setTooltipContent(tooltipEl, tooltip) {
const bodyLines = tooltip.body.map(bodyItem => bodyItem.lines);
if (bodyLines.length === 0) return;
const titleLines = tooltip.title || [];
let tooltipHtml = "<thead>";
for (const title of titleLines) {
tooltipHtml += `<tr><th>${title}</th></tr>`;
}
tooltipHtml += "</thead><tbody>";
const devicePixel = (1 / window.devicePixelRatio).toFixed(1);
let printed = 0;
for (const [i, body] of bodyLines.entries()) {
const labelColors = tooltip.labelColors[i];
const style =
`background-color: ${labelColors.backgroundColor}; ` +
`outline: 1px solid ${labelColors.backgroundColor}; ` +
`border: ${devicePixel}px solid #fff`;
const span = `<span class="chartjs-tooltip-key" style="${style}"></span>`;
const num = body[0].split(": ");
// Do not display entries with value of 0 in bar chart,
// but pass through entries with "0.0%" (in pie charts)
if (num[1] !== "0") {
tooltipHtml += `<tr><td>${span}${body}</td></tr>`;
printed++;
}
}
if (printed < 1) {
tooltipHtml += "<tr><td>No activity recorded</td></tr>";
}
tooltipHtml += "</tbody>";
const tableRoot = tooltipEl.querySelector("table");
tableRoot.innerHTML = tooltipHtml;
}
function positionTooltip(tooltipEl, tooltip, context) {
if (tooltip.opacity === 0 || tooltipEl.style.opacity === 0) return;
const canvasPos = context.chart.canvas.getBoundingClientRect();
const boxPos = tooltipEl.ancestor.getBoundingClientRect();
const offsetX = canvasPos.left - boxPos.left;
const offsetY = canvasPos.top - boxPos.top;
const tooltipWidth = tooltipEl.offsetWidth;
const tooltipHeight = tooltipEl.offsetHeight;
const caretX = tooltip.caretX;
const caretY = tooltip.caretY;
const caretPadding = tooltip.options.caretPadding;
let tooltipX;
let tooltipY;
let arrowX;
const { caretX, caretY } = tooltip;
const { caretPadding } = tooltip.options;
const arrowMinIndent = 2 * tooltip.options.cornerRadius;
const arrowSize = 5;
// Check if this is a queryOverTimeChart or clientsChart - these should stick to x-axis
const canvasId = context.chart.canvas.id;
const isTimelineChart = canvasId === "queryOverTimeChart" || canvasId === "clientsChart";
let tooltipX = offsetX + caretX;
let arrowX;
// Compute X position
if ($(document).width() > 2 * tooltip.width || tooltip.xAlign !== "center") {
// If the viewport is wide enough, let the tooltip follow the caret position
tooltipX = offsetX + caretX;
if (tooltip.yAlign === "top" || tooltip.yAlign === "bottom") {
switch (tooltip.xAlign) {
case "center": {
// set a minimal X position to 5px to prevent
// the tooltip to stick out left of the viewport
const minX = 5;
if (2 * tooltipX < tooltipWidth + minX) {
arrowX = tooltipX - minX;
tooltipX = minX;
} else {
tooltipX -= tooltipWidth / 2;
}
break;
}
case "left":
tooltipX -= arrowMinIndent;
arrowX = arrowMinIndent;
break;
case "right":
tooltipX -= tooltipWidth - arrowMinIndent;
arrowX = tooltipWidth - arrowMinIndent;
break;
default:
break;
if (tooltip.yAlign === "top" || tooltip.yAlign === "bottom") {
switch (tooltip.xAlign) {
case "center": {
// Set a minimal X position to 5px to prevent
// the tooltip to stick out left of the viewport
const minX = 5;
tooltipX = Math.max(minX, tooltipX - tooltipWidth / 2);
arrowX = tooltip.caretX - (tooltipX - offsetX);
break;
}
} else if (tooltip.yAlign === "center") {
switch (tooltip.xAlign) {
case "left":
tooltipX += caretPadding;
break;
case "right":
tooltipX -= tooltipWidth - caretPadding;
break;
case "center":
tooltipX -= tooltipWidth / 2;
break;
default:
break;
case "left": {
tooltipX -= arrowMinIndent;
arrowX = arrowMinIndent;
break;
}
case "right": {
tooltipX -= tooltipWidth - arrowMinIndent;
arrowX = tooltipWidth - arrowMinIndent;
break;
}
// No default
}
} else {
// compute the tooltip's center inside ancestor element
} else if (tooltip.yAlign === "center") {
switch (tooltip.xAlign) {
case "left": {
tooltipX += caretPadding;
break;
}
case "right": {
tooltipX -= tooltipWidth - caretPadding;
break;
}
case "center": {
tooltipX -= tooltipWidth / 2;
break;
}
// No default
}
}
// Adjust X position if tooltip is centered inside ancestor
if (document.documentElement.clientWidth <= 2 * tooltip.width && tooltip.xAlign === "center") {
tooltipX = (tooltipEl.ancestor.offsetWidth - tooltipWidth) / 2;
// move the tooltip if the arrow would stick out to the left
if (offsetX + caretX - arrowMinIndent < tooltipX) {
tooltipX = offsetX + caretX - arrowMinIndent;
}
// move the tooltip if the arrow would stick out to the right
if (offsetX + caretX - tooltipWidth + arrowMinIndent > tooltipX) {
tooltipX = offsetX + caretX - tooltipWidth + arrowMinIndent;
}
tooltipX = Math.max(tooltipX, offsetX + caretX - arrowMinIndent); // Prevent left overflow
tooltipX = Math.min(tooltipX, offsetX + caretX - tooltipWidth + arrowMinIndent); // Prevent right overflow
arrowX = offsetX + caretX - tooltipX;
}
// Compute Y position
switch (tooltip.yAlign) {
case "top":
tooltipY = offsetY + caretY + arrowSize + caretPadding;
break;
case "center":
tooltipY = offsetY + caretY - tooltipHeight / 2;
if (tooltip.xAlign === "left") {
tooltipX += arrowSize;
} else if (tooltip.xAlign === "right") {
tooltipX -= arrowSize;
let tooltipY;
if (isTimelineChart) {
// For timeline charts, always position tooltip below the chart with caret pointing to x-axis
const chartArea = context.chart.chartArea;
const canvasBottom = chartArea.bottom;
tooltipY = offsetY + canvasBottom + arrowSize + caretPadding;
// Ensure the arrow points to the correct X position
arrowX = tooltip.caretX - (tooltipX - offsetX);
} else {
tooltipY = offsetY + caretY;
switch (tooltip.yAlign) {
case "top": {
tooltipY += arrowSize + caretPadding;
break;
}
break;
case "bottom":
tooltipY = offsetY + caretY - tooltipHeight - arrowSize - caretPadding;
break;
default:
break;
case "center": {
tooltipY -= tooltipHeight / 2;
if (tooltip.xAlign === "left") tooltipX += arrowSize;
if (tooltip.xAlign === "right") tooltipX -= arrowSize;
break;
}
case "bottom": {
tooltipY -= tooltipHeight + arrowSize + caretPadding;
break;
}
// No default
}
}
// Position tooltip and display
tooltipEl.style.top = tooltipY.toFixed(1) + "px";
tooltipEl.style.left = tooltipX.toFixed(1) + "px";
if (arrowX === undefined) {
tooltipEl.querySelector(".arrow").style.left = "";
} else {
tooltipEl.style.top = `${tooltipY.toFixed(1)}px`;
tooltipEl.style.left = `${tooltipX.toFixed(1)}px`;
// Set arrow position
const arrowEl = tooltipEl.querySelector(".arrow");
let arrowLeftPosition = "";
if (arrowX !== undefined) {
// Calculate percentage X value depending on the tooltip's
// width to avoid hanging arrow out on tooltip width changes
const arrowXpercent = ((100 / tooltipWidth) * arrowX).toFixed(1);
tooltipEl.querySelector(".arrow").style.left = arrowXpercent + "%";
arrowLeftPosition = `${arrowXpercent}%`;
}
tooltipEl.style.opacity = 1;
};
arrowEl.style.left = arrowLeftPosition;
}
globalThis.doughnutTooltip = tooltipLabel => {
if (tooltipLabel.parsed === 0) return "";
// eslint-disable-next-line no-unused-vars
function doughnutTooltip(tooltipLabel) {
let percentageTotalShown = tooltipLabel.chart._metasets[0].total.toFixed(1);
// tooltipLabel.chart._metasets[0].total returns the total percentage of the shown slices
// to compensate rounding errors we round to one decimal
const label = " " + tooltipLabel.label;
let percentageTotalShown = tooltipLabel.chart._metasets[0].total.toFixed(1);
const label = ` ${tooltipLabel.label}`;
let itemPercentage;
// if we only show < 1% percent of all, show each item with two decimals
@@ -327,41 +390,32 @@ function doughnutTooltip(tooltipLabel) {
tooltipLabel.parsed.toFixed(1) === "0.0" ? "< 0.1" : tooltipLabel.parsed.toFixed(1);
}
// even if no doughnut slice is hidden, sometimes percentageTotalShown is slightly less then 100
// even if no doughnut slice is hidden, sometimes percentageTotalShown is slightly less than 100
// we therefore use 99.9 to decide if slices are hidden (we only show with 0.1 precision)
if (percentageTotalShown > 99.9) {
// All items shown
return label + ": " + itemPercentage + "%";
return `${label}: ${itemPercentage}%`;
}
// set percentageTotalShown again without rounding to account
// for cases where the total shown percentage would be <0.1% of all
percentageTotalShown = tooltipLabel.chart._metasets[0].total;
const percentageOfShownItems = ((tooltipLabel.parsed * 100) / percentageTotalShown).toFixed(1);
return (
label +
":<br>&bull; " +
itemPercentage +
"% of all data<br>&bull; " +
((tooltipLabel.parsed * 100) / percentageTotalShown).toFixed(1) +
"% of shown items"
`${label}:<br>&bull; ${itemPercentage}% of all data<br>` +
`&bull; ${percentageOfShownItems}% of shown items`
);
}
};
// chartjs plugin used by the custom doughnut legend
const getOrCreateLegendList = (chart, id) => {
function getOrCreateLegendList(id) {
const legendContainer = document.getElementById(id);
let listContainer = legendContainer.querySelector("ul");
if (listContainer) return listContainer;
if (!listContainer) {
listContainer = document.createElement("ul");
listContainer.style.display = "flex";
listContainer.style.flexDirection = "column";
listContainer.style.flexWrap = "wrap";
listContainer.style.margin = 0;
listContainer.style.padding = 0;
legendContainer.append(listContainer);
}
listContainer = document.createElement("ul");
legendContainer.append(listContainer);
return listContainer;
};
}

View File

@@ -29,12 +29,11 @@ const REFRESH_INTERVAL = {
clients: 600_000, // 10 min (dashboard)
};
function secondsTimeSpanToHMS(s) {
const h = Math.floor(s / 3600); //Get whole hours
s -= h * 3600;
const m = Math.floor(s / 60); //Get remaining minutes
s -= m * 60;
return h + ":" + (m < 10 ? "0" + m : m) + ":" + (s < 10 ? "0" + s : s); //zero padding on minutes and seconds
function secondsTimeSpanToHMS(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
function piholeChanged(blocking, timer = null) {
@@ -109,12 +108,6 @@ function countDown() {
}
function checkBlocking() {
// Skip if page is hidden
if (document.hidden) {
utils.setTimer(checkBlocking, REFRESH_INTERVAL.blocking);
return;
}
$.ajax({
url: document.body.dataset.apiurl + "/dns/blocking",
method: "GET",
@@ -248,28 +241,26 @@ function updateFtlInfo() {
$("#num_lists").text(intl.format(database.lists));
$("#num_gravity").text(intl.format(database.gravity));
$("#num_allowed")
.text(intl.format(database.domains.allowed + database.regex.allowed))
.text(intl.format(database.domains.allowed.enabled + database.regex.allowed.enabled))
.attr(
"title",
"Allowed: " +
intl.format(database.domains.allowed) +
intl.format(database.domains.allowed.enabled) +
" exact domains and " +
intl.format(database.regex.allowed) +
intl.format(database.regex.allowed.enabled) +
" regex filters are enabled"
);
$("#num_denied")
.text(intl.format(database.domains.denied + database.regex.denied))
.text(intl.format(database.domains.denied.enabled + database.regex.denied.enabled))
.attr(
"title",
"Denied: " +
intl.format(database.domains.denied) +
intl.format(database.domains.denied.enabled) +
" exact domains and " +
intl.format(database.regex.denied) +
intl.format(database.regex.denied.enabled) +
" regex filters are enabled"
);
updateQueryFrequency(intl, ftl.query_frequency);
$("#sysinfo-cpu-ftl").text("(" + ftl["%cpu"].toFixed(1) + "% used by FTL)");
$("#sysinfo-ram-ftl").text("(" + ftl["%mem"].toFixed(1) + "% used by FTL)");
$("#sysinfo-pid-ftl").text(ftl.pid);
const startdate = moment()
.subtract(ftl.uptime, "milliseconds")
@@ -353,18 +344,20 @@ function updateSystemInfo() {
);
$("#cpu").prop(
"title",
"Load averages for the past 1, 5, and 15 minutes\non a system with " +
"CPU usage: " +
system.cpu["%cpu"].toFixed(1) +
"%\nLoad averages for the past 1, 5, and 15 minutes\non a system with " +
system.cpu.nprocs +
" core" +
(system.cpu.nprocs > 1 ? "s" : "") +
" running " +
system.procs +
" processes " +
" processes" +
(system.cpu.load.raw[0] > system.cpu.nprocs
? " (load is higher than the number of cores)"
? "\n(load is higher than the number of cores)"
: "")
);
$("#sysinfo-cpu").html(
$("#sysinfo-cpu").text(
system.cpu["%cpu"].toFixed(1) +
"% on " +
system.cpu.nprocs +
@@ -375,6 +368,9 @@ function updateSystemInfo() {
" processes"
);
$("#sysinfo-cpu-ftl").text("(" + system.ftl["%cpu"].toFixed(1) + "% used by FTL)");
$("#sysinfo-ram-ftl").text("(" + system.ftl["%mem"].toFixed(1) + "% used by FTL)");
const startdate = moment()
.subtract(system.uptime, "seconds")
.format("dddd, MMMM Do YYYY, HH:mm:ss");
@@ -561,7 +557,7 @@ function updateVersionInfo() {
v.name +
"</strong> " +
localVersion +
'&nbsp&middot; <a class="lookatme" lookatme-text="Update available!" href="' +
'&nbsp;&middot; <a class="lookatme" data-lookatme-text="Update available!" href="' +
v.url +
'" rel="noopener noreferrer" target="_blank">Update available!</a></li>'
);
@@ -734,9 +730,6 @@ function addAdvancedInfo() {
advancedInfoTarget.append(
"Render time: " + (totaltime > 0.5 ? totaltime.toFixed(1) : totaltime.toFixed(3)) + " ms"
);
// Show advanced info
advancedInfoTarget.show();
}
$(() => {

View File

@@ -5,87 +5,160 @@
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
/* global apiFailure:false, utils:false */
"use strict";
function eventsource() {
const alInfo = $("#alInfo");
const alSuccess = $("#alSuccess");
const ta = $("#output");
const $alertInfo = $("#alertInfo");
const $alertSuccess = $("#alertSuccess");
const outputElement = document.getElementById("output");
const gravityBtn = document.getElementById("gravityBtn");
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute("content");
const url = `${document.body.dataset.apiurl}/action/gravity`;
ta.html("");
ta.show();
alInfo.show();
alSuccess.hide();
if (outputElement.innerHTML.length > 0) {
outputElement.innerHTML = "";
}
fetch(document.body.dataset.apiurl + "/action/gravity", {
if (!outputElement.classList.contains("d-none")) {
outputElement.classList.add("d-none");
}
$alertSuccess.hide();
$alertInfo.show();
fetch(url, {
method: "POST",
headers: { "X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr("content") },
headers: { "X-CSRF-TOKEN": csrfToken },
})
// Retrieve its body as ReadableStream
.then(response => (response.ok ? response : apiFailure(response)))
// Retrieve the response as ReadableStream
.then(response => {
const reader = response.body.getReader();
return new ReadableStream({
start(controller) {
return pump();
function pump() {
return reader.read().then(({ done, value }) => {
// When no more data needs to be consumed, close the stream
if (done) {
controller.close();
alInfo.hide();
$("#gravityBtn").prop("disabled", false);
return;
}
// Enqueue the next data chunk into our target stream
controller.enqueue(value);
const string = new TextDecoder().decode(value);
parseLines(ta, string);
if (string.includes("Done.")) {
alSuccess.show();
}
return pump();
});
}
},
return handleResponseStream({
response,
outputElement,
alertInfo: $alertInfo,
gravityBtn,
alertSuccess: $alertSuccess,
});
})
.catch(error => console.error(error)); // eslint-disable-line no-console
}
$("#gravityBtn").on("click", () => {
$("#gravityBtn").prop("disabled", true);
eventsource();
});
function handleResponseStream({ response, outputElement, alertInfo, gravityBtn, alertSuccess }) {
outputElement.classList.remove("d-none");
// Handle hiding of alerts
$(() => {
$("[data-hide]").on("click", function () {
$(this)
.closest("." + $(this).attr("data-hide"))
.hide();
const reader = response.body.getReader();
function pump(controller) {
return reader.read().then(({ done, value }) => {
// When no more data needs to be consumed, close the stream
if (done) {
controller.close();
alertInfo.hide();
gravityBtn.removeAttribute("disabled");
return;
}
// Enqueue the next data chunk into our target stream
controller.enqueue(value);
const text = new TextDecoder().decode(value);
parseLines(outputElement, text);
if (text.includes("Done.")) {
alertSuccess.show();
}
return pump(controller);
});
}
return new ReadableStream({
start(controller) {
return pump(controller);
},
});
});
}
function parseLines(ta, str) {
// str can contain multiple lines.
function parseLines(outputElement, text) {
// text can contain multiple lines.
// We want to split the text before an "OVER" escape sequence to allow overwriting previous line when needed
// Splitting the text on "\r"
const lines = str.split(/(?=\r)/g);
const lines = text.split(/(?=\r)/g);
for (let line of lines) {
// Escape HTML to prevent XSS attacks (both in adlist URL and non-domain entries)
line = utils.escapeHtml(line);
if (line[0] === "\r") {
// This line starts with the "OVER" sequence. Replace them with "\n" before print
line = line.replaceAll("\r", "\n").replaceAll("\r", "\n");
line = line.replaceAll("\r\u001B[K", "\n").replaceAll("\r", "\n");
// Last line from the textarea will be overwritten, so we remove it
ta.text(ta.text().substring(0, ta.text().lastIndexOf("\n")));
const lastLineIndex = outputElement.innerHTML.lastIndexOf("\n");
outputElement.innerHTML = outputElement.innerHTML.substring(0, lastLineIndex);
}
// Track the number of opening spans
let spanCount = 0;
// Mapping of ANSI escape codes to their corresponding CSS class names.
const ansiMappings = {
"\u001B[1m": "text-bold", //COL_BOLD
"\u001B[90m": "log-gray", //COL_GRAY
"\u001B[91m": "log-red", //COL_RED
"\u001B[32m": "log-green", //COL_GREEN
"\u001B[33m": "log-yellow", //COL_YELLOW
"\u001B[94m": "log-blue", //COL_BLUE
"\u001B[95m": "log-purple", //COL_PURPLE
"\u001B[96m": "log-cyan", //COL_CYAN
};
// Create a regex that matches all ANSI codes (including reset)
/* eslint-disable-next-line no-control-regex */
const ansiRegex = /(\u001B\[(?:1|90|91|32|33|94|95|96|0)m)/g;
// Process the line sequentially, replacing ANSI codes with their corresponding HTML spans
// we use a counter to keep track of how many spans are open and close the correct number of spans when we encounter a reset code
/* eslint-disable-next-line unicorn/prefer-string-replace-all */
line = line.replace(ansiRegex, match => {
if (match === "\u001B[0m") {
// Reset/close all open spans
const closingTags = "</span>".repeat(spanCount);
spanCount = 0;
return closingTags;
}
if (ansiMappings[match]) {
// Opening span
spanCount++;
return `<span class="${ansiMappings[match]}">`;
}
return match; // Return unchanged if not recognized
});
// Append the new text to the end of the output
ta.append(line);
outputElement.innerHTML += line;
}
}
document.addEventListener("DOMContentLoaded", () => {
const gravityBtn = document.getElementById("gravityBtn");
gravityBtn.addEventListener("click", () => {
gravityBtn.disabled = true;
eventsource();
});
// Handle hiding of alerts
const dataHideElements = document.querySelectorAll("[data-hide]");
for (const element of dataHideElements) {
element.addEventListener("click", () => {
const hideClass = element.dataset.hide;
const closestElement = element.closest(`.${hideClass}`);
if (closestElement) $(closestElement).hide();
});
}
});

View File

@@ -92,7 +92,6 @@ $(() => {
});
});
// eslint-disable-next-line no-unused-vars
function initTable() {
table = $("#clientsTable").DataTable({
processing: true,
@@ -322,7 +321,7 @@ function initTable() {
}
table.on("init select deselect", () => {
utils.changeBulkDeleteStates(table);
utils.changeTableButtonStates(table);
});
table.on("order.dt", () => {

View File

@@ -115,7 +115,7 @@ function delGroupItems(type, ids, table, listType = undefined) {
// Clear selection after deletion
table.rows().deselect();
utils.changeBulkDeleteStates(table);
utils.changeTableButtonStates(table);
// Update number of <type> items in the sidebar
updateFtlInfo();

View File

@@ -99,7 +99,6 @@ function hideSuggestDomains() {
$("#suggest_domains").slideUp("fast");
}
// eslint-disable-next-line no-unused-vars
function initTable() {
table = $("#domainsTable").DataTable({
processing: true,
@@ -398,7 +397,7 @@ function initTable() {
}
table.on("init select deselect", () => {
utils.changeBulkDeleteStates(table);
utils.changeTableButtonStates(table);
});
table.on("order.dt", () => {

View File

@@ -170,7 +170,6 @@ function setTypeIcon(type) {
return `<i class='fa fa-fw ${iconClass}' title='${title}\nClick for details about this list'></i> `;
}
// eslint-disable-next-line no-unused-vars
function initTable() {
table = $("#listsTable").DataTable({
processing: true,
@@ -235,23 +234,20 @@ function initTable() {
if (data.address.startsWith("file://")) {
// Local files cannot be downloaded from a distant client so don't show
// a link to such a list here
$("td:eq(3)", row).html(
'<code id="address_' +
dataId +
'" class="breakall">' +
utils.escapeHtml(data.address) +
"</code>"
);
const codeElem = document.createElement("code");
codeElem.id = "address_" + dataId;
codeElem.className = "breakall";
codeElem.textContent = data.address;
$("td:eq(3)", row).empty().append(codeElem);
} else {
$("td:eq(3)", row).html(
'<a id="address_' +
dataId +
'" class="breakall" href="' +
encodeURI(data.address) +
'" target="_blank" rel="noopener noreferrer">' +
utils.escapeHtml(data.address) +
"</a>"
);
const aElem = document.createElement("a");
aElem.id = "address_" + dataId;
aElem.className = "breakall";
aElem.href = data.address;
aElem.target = "_blank";
aElem.rel = "noopener noreferrer";
aElem.textContent = data.address;
$("td:eq(3)", row).empty().append(aElem);
}
$("td:eq(4)", row).html(
@@ -440,7 +436,7 @@ function initTable() {
});
table.on("init select deselect", () => {
utils.changeBulkDeleteStates(table);
utils.changeTableButtonStates(table);
});
table.on("order.dt", () => {
@@ -519,12 +515,12 @@ function addList(event) {
}
$.ajax({
url: document.body.dataset.apiurl + "/lists",
url: document.body.dataset.apiurl + "/lists?type=" + encodeURIComponent(type),
method: "post",
dataType: "json",
processData: false,
contentType: "application/json; charset=utf-8",
data: JSON.stringify({ address: addresses, comment, type, groups: group }),
data: JSON.stringify({ address: addresses, comment, groups: group }),
success(data) {
utils.enableAll();
utils.listsAlert(type + "list", addresses, data);

View File

@@ -207,7 +207,7 @@ $(() => {
table.rows(0).deselect();
}
utils.changeBulkDeleteStates(table);
utils.changeTableButtonStates(table);
});
table.on("order.dt", () => {

View File

@@ -226,7 +226,7 @@ function updateClientsOverTime() {
});
}
const upstreams = {};
const upstreamIPs = [];
function updateForwardDestinationsPie() {
$.getJSON(document.body.dataset.apiurl + "/stats/upstreams", data => {
const v = [];
@@ -248,11 +248,8 @@ function updateForwardDestinationsPie() {
label += "#" + item.port;
}
// Store upstreams for generating links to the Query Log
upstreams[label] = item.ip;
if (item.port > 0) {
upstreams[label] += "#" + item.port;
}
// Store upstreams IPs for generating links to the Query Log
upstreamIPs.push(item.port > 0 ? item.ip + "#" + item.port : item.ip);
const percent = (100 * item.count) / sum;
values.push([label, percent, THEME_COLORS[i++ % THEME_COLORS.length]]);
@@ -521,8 +518,8 @@ function labelWithPercentage(tooltipLabel, skipZero = false) {
// Sum all queries for the current time by iterating over all keys in the
// current dataset
let sum = 0;
for (const value of Object.values(tooltipLabel.parsed._stacks.y)) {
if (value === undefined) continue;
for (const [key, value] of Object.entries(tooltipLabel.parsed._stacks.y)) {
if (key.startsWith("_") || value === undefined) continue;
const num = Number.parseInt(value, 10);
if (num) sum += num;
}
@@ -639,9 +636,11 @@ $(() => {
display: false,
},
tooltip: {
enabled: true,
// Disable the on-canvas tooltip
enabled: false,
intersect: false,
yAlign: "bottom",
external: customTooltips,
yAlign: "top",
itemSort(a, b) {
return b.datasetIndex - a.datasetIndex;
},
@@ -656,7 +655,7 @@ $(() => {
return "Queries from " + from + " to " + to;
},
label(tooltipLabel) {
return labelWithPercentage(tooltipLabel);
return labelWithPercentage(tooltipLabel, true);
},
},
},
@@ -893,6 +892,8 @@ $(() => {
elements: {
arc: {
borderColor: $(".box").css("background-color"),
hoverBorderColor: $(".box").css("background-color"),
hoverOffset: 10,
},
},
plugins: {
@@ -917,6 +918,9 @@ $(() => {
animation: {
duration: 750,
},
layout: {
padding: 10,
},
},
});
@@ -939,6 +943,8 @@ $(() => {
elements: {
arc: {
borderColor: $(".box").css("background-color"),
hoverBorderColor: $(".box").css("background-color"),
hoverOffset: 10,
},
},
plugins: {
@@ -963,6 +969,9 @@ $(() => {
animation: {
duration: 750,
},
layout: {
padding: 10,
},
},
});

View File

@@ -5,462 +5,526 @@
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
/* global utils: false */
/* global utils:false, apiFailure:false */
"use strict";
$(() => {
$.ajax({
url: document.body.dataset.apiurl + "/network/gateway",
data: { detailed: true },
}).done(data => {
const intl = new Intl.NumberFormat();
const gateway = data.gateway;
// Get all objects in gateway that has family == "inet"
const inet = gateway.find(obj => obj.family === "inet");
// Get first object in gateway that has family == "inet6"
const inet6 = gateway.find(obj => obj.family === "inet6");
// Create a set of the gateways when they are found
const gateways = new Set();
if (inet !== undefined) {
gateways.add(inet.gateway);
}
document.addEventListener("DOMContentLoaded", () => {
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute("content");
const url = `${document.body.dataset.apiurl}/network/gateway?detailed=true`;
if (inet6 !== undefined) {
gateways.add(inet6.gateway);
}
fetch(url, {
method: "GET",
headers: {
"X-CSRF-TOKEN": csrfToken,
},
})
.then(response => (response.ok ? response.json() : apiFailure(response)))
.then(data => {
const intl = new Intl.NumberFormat();
const gateways = extractGateways(data.gateway);
const { interfaces, masterInterfaces } = processInterfaces(data.interfaces, gateways, intl);
const masterInterfacesSorted = sortInterfaces(interfaces, masterInterfaces);
const json = masterInterfacesSorted.map(iface => interfaces[iface]);
const interfaces = {};
const masterInterfaces = {};
// For each interface in data.interface, create a new object and push it to json
for (const iface of data.interfaces) {
const carrierColor = iface.carrier ? "text-green" : "text-red";
let stateText = iface.state.toUpperCase();
if (stateText === "UNKNOWN" && iface.flags !== undefined && iface.flags.length > 0) {
if (iface.flags.includes("pointopoint")) {
// WireGuards, etc. -> the typo is intentional
stateText = "P2P";
} else if (iface.flags.includes("loopback")) {
// Loopback interfaces
stateText = "LOOPBACK";
}
}
const status = `<span class="${carrierColor}">${stateText}</span>`;
let master = null;
if (iface.master !== undefined) {
// Find interface.master in data.interfaces
master = data.interfaces.find(obj => obj.index === iface.master).name;
}
// Show an icon for indenting slave interfaces
const indentIcon =
master === null ? "" : "<span class='child-interface-icon'>&nbsp;&rdca;</span> ";
const obj = {
text: indentIcon + iface.name + " - " + status,
class: gateways.has(iface.name) ? "text-bold" : null,
icon: master === null ? "fa fa-network-wired fa-fw" : "",
nodes: [],
};
if (master !== null) {
obj.nodes.push({
text: "Master interface: <code>" + utils.escapeHtml(master) + "</code>",
icon: "fa fa-network-wired fa-fw",
});
if (master in masterInterfaces) {
masterInterfaces[master].push(iface.name);
} else {
masterInterfaces[master] = [iface.name];
}
}
if (iface.speed) {
obj.nodes.push({
text: "Speed: " + intl.format(iface.speed) + " Mbit/s",
icon: "fa fa-tachometer-alt fa-fw",
});
}
if (iface.type !== undefined) {
obj.nodes.push({
text: "Type: " + utils.escapeHtml(iface.type),
icon: "fa fa-network-wired fa-fw",
});
}
if (iface.flags !== undefined && iface.flags.length > 0) {
obj.nodes.push({
text: "Flags: " + utils.escapeHtml(iface.flags.join(", ")),
icon: "fa fa-flag fa-fw",
});
}
if (iface.address !== undefined) {
let extra = "";
if (iface.perm_address !== undefined && iface.perm_address !== iface.address) {
extra = " (permanent: <code>" + utils.escapeHtml(iface.perm_address) + "</code>)";
}
obj.nodes.push({
text: "Hardware address: <code>" + utils.escapeHtml(iface.address) + "</code>" + extra,
icon: "fa fa-map-marker-alt fa-fw",
});
}
if (iface.addresses !== undefined) {
const addrs = {
text:
iface.addresses.length +
(iface.addresses.length === 1 ? " address" : " addresses") +
" connected to interface",
icon: "fa fa-map-marker-alt fa-fw",
nodes: [],
};
for (const addr of iface.addresses) {
let extraaddr = "";
if (addr.prefixlen !== undefined) {
extraaddr += " / <code>" + addr.prefixlen + "</code>";
}
if (addr.address_type !== undefined) {
let familyextra = "";
if (addr.family === "inet") {
familyextra = "IPv4 ";
} else if (addr.family === "inet6") {
familyextra = "IPv6 ";
}
extraaddr += " (" + familyextra + utils.escapeHtml(addr.address_type) + ")";
}
let family = "";
if (addr.family !== undefined) {
family = addr.family + "</code> <code>";
}
const jaddr = {
text:
"Address: <code>" + family + utils.escapeHtml(addr.address) + "</code>" + extraaddr,
icon: "fa fa-map-marker-alt fa-fw",
nodes: [],
};
if (addr.local !== undefined) {
jaddr.nodes.push({
text: "Local: <code>" + utils.escapeHtml(addr.local) + "</code>",
icon: "fa fa-map-marker-alt fa-fw",
});
}
if (addr.broadcast !== undefined) {
jaddr.nodes.push({
text: "Broadcast: <code>" + utils.escapeHtml(addr.broadcast) + "</code>",
icon: "fa fa-map-marker-alt fa-fw",
});
}
if (addr.scope !== undefined) {
jaddr.nodes.push({
text: "Scope: " + utils.escapeHtml(addr.scope),
icon: "fa fa-map-marker-alt fa-fw",
});
}
if (addr.flags !== undefined && addr.flags.length > 0) {
jaddr.nodes.push({
text: "Flags: " + utils.escapeHtml(addr.flags.join(", ")),
icon: "fa fa-map-marker-alt fa-fw",
});
}
if (addr.prefered !== undefined) {
const pref =
addr.prefered === 4_294_967_295 ? "forever" : intl.format(addr.prefered) + " s";
jaddr.nodes.push({
text: "Preferred lifetime: " + pref,
icon: "fa fa-clock fa-fw",
});
}
if (addr.valid !== undefined) {
const valid = addr.valid === 4_294_967_295 ? "forever" : intl.format(addr.valid) + " s";
jaddr.nodes.push({
text: "Valid lifetime: " + valid,
icon: "fa fa-clock fa-fw",
});
}
if (addr.cstamp !== undefined) {
jaddr.nodes.push({
text: "Created: " + new Date(addr.cstamp * 1000).toLocaleString(),
icon: "fa fa-clock fa-fw",
});
}
if (addr.tstamp !== undefined) {
jaddr.nodes.push({
text: "Last updated: " + new Date(addr.tstamp * 1000).toLocaleString(),
icon: "fa fa-clock fa-fw",
});
}
addrs.nodes.push(jaddr);
}
obj.nodes.push(addrs);
}
if (iface.stats !== undefined) {
const stats = {
text: "Statistics",
icon: "fa fa-chart-line fa-fw",
expanded: false,
nodes: [],
};
if (iface.stats.rx_bytes !== undefined) {
stats.nodes.push({
text:
"RX bytes: " +
intl.format(iface.stats.rx_bytes.value) +
" " +
iface.stats.rx_bytes.unit,
icon: "fa fa-download fa-fw",
});
}
if (iface.stats.tx_bytes !== undefined) {
stats.nodes.push({
text:
"TX bytes: " +
intl.format(iface.stats.tx_bytes.value) +
" " +
iface.stats.tx_bytes.unit,
icon: "fa fa-upload fa-fw",
});
}
if (iface.stats.rx_packets !== undefined) {
stats.nodes.push({
text: "RX packets: " + intl.format(iface.stats.rx_packets),
icon: "fa fa-download fa-fw",
});
}
if (iface.stats.rx_errors !== undefined) {
stats.nodes.push({
text:
"RX errors: " +
intl.format(iface.stats.rx_errors) +
" (" +
((iface.stats.rx_errors / iface.stats.rx_packets) * 100).toFixed(1) +
"%)",
icon: "fa fa-download fa-fw",
});
}
if (iface.stats.rx_dropped !== undefined) {
stats.nodes.push({
text:
"RX dropped: " +
intl.format(iface.stats.rx_dropped) +
" (" +
((iface.stats.rx_dropped / iface.stats.rx_packets) * 100).toFixed(1) +
"%)",
icon: "fa fa-download fa-fw",
});
}
if (iface.stats.tx_packets !== undefined) {
stats.nodes.push({
text: "TX packets: " + intl.format(iface.stats.tx_packets),
icon: "fa fa-upload fa-fw",
});
}
if (iface.stats.tx_errors !== undefined) {
stats.nodes.push({
text:
"TX errors: " +
intl.format(iface.stats.tx_errors) +
" (" +
((iface.stats.tx_errors / iface.stats.tx_packets) * 100).toFixed(1) +
"%)",
icon: "fa fa-upload fa-fw",
});
}
if (iface.stats.tx_dropped !== undefined) {
stats.nodes.push({
text:
"TX dropped: " +
intl.format(iface.stats.tx_dropped) +
" (" +
((iface.stats.tx_dropped / iface.stats.tx_packets) * 100).toFixed(1) +
"%)",
icon: "fa fa-upload fa-fw",
});
}
if (iface.stats.multicast !== undefined) {
stats.nodes.push({
text: "Multicast: " + intl.format(iface.stats.multicast),
icon: "fa fa-broadcast-tower fa-fw",
});
}
if (iface.stats.collisions !== undefined) {
stats.nodes.push({
text: "Collisions: " + intl.format(iface.stats.collisions),
icon: "fa fa-exchange-alt fa-fw",
});
}
obj.nodes.push(stats);
}
const furtherDetails = {
text: "Further details",
icon: "fa fa-info-circle fa-fw",
expanded: false,
nodes: [],
};
furtherDetails.nodes.push(
{
text:
"Carrier: " +
(iface.carrier
? "<span class='text-green'>Connected</span>"
: "<span class='text-red'>Disconnected</span>"),
icon: "fa fa-link fa-fw",
},
{
text: "State: " + utils.escapeHtml(iface.state.toUpperCase()),
icon: "fa fa-server fa-fw",
}
);
if (iface.parent_dev_name !== undefined) {
let extra = "";
if (iface.parent_dev_bus_name !== undefined) {
extra = " @ " + utils.escapeHtml(iface.parent_dev_bus_name);
}
furtherDetails.nodes.push({
text:
"Parent device: <code>" + utils.escapeHtml(iface.parent_dev_name) + extra + "</code>",
icon: "fa fa-network-wired fa-fw",
});
}
if (iface.carrier_changes !== undefined) {
furtherDetails.nodes.push({
text: "Carrier changes: " + intl.format(iface.carrier_changes),
icon: "fa fa-exchange-alt fa-fw",
});
}
if (iface.broadcast) {
furtherDetails.nodes.push({
text: "Broadcast: <code>" + utils.escapeHtml(iface.broadcast) + "</code>",
icon: "fa fa-broadcast-tower fa-fw",
});
}
if (iface.mtu) {
let extra = "";
if (iface.min_mtu !== undefined && iface.max_mtu !== undefined) {
extra +=
" (min: " +
intl.format(iface.min_mtu) +
" bytes, max: " +
intl.format(iface.max_mtu) +
" bytes)";
}
furtherDetails.nodes.push({
text: "MTU: " + intl.format(iface.mtu) + " bytes" + extra,
icon: "fa fa-arrows-alt-h fa-fw",
});
}
if (iface.txqlen) {
furtherDetails.nodes.push({
text: "TX queue length: " + intl.format(iface.txqlen),
icon: "fa fa-file-upload fa-fw",
});
}
if (iface.promiscuity !== undefined) {
furtherDetails.nodes.push({
text: "Promiscuity mode: " + (iface.promiscuity ? "Yes" : "No"),
icon: "fa fa-eye fa-fw",
});
}
if (iface.qdisc !== undefined) {
furtherDetails.nodes.push({
text: "Scheduler: " + utils.escapeHtml(iface.qdisc),
icon: "fa fa-network-wired fa-fw",
});
}
if (furtherDetails.nodes.length > 0) {
obj.nodes.push(furtherDetails);
}
interfaces[iface.name] = obj;
}
// Sort interfaces based on masterInterfaces. If an item is found in
// masterInterfaces, it should be placed after the master interface
const ifaces = Object.keys(interfaces);
const interfaceList = Object.keys(masterInterfaces);
// Add slave interfaces next to master interfaces
for (const master of interfaceList) {
if (master in masterInterfaces) {
for (const slave of masterInterfaces[master]) {
ifaces.splice(ifaces.indexOf(slave), 1);
interfaceList.splice(interfaceList.indexOf(master) + 1, 0, slave);
}
}
}
// Add interfaces that are not slaves at the top of the list (in reverse order)
for (const iface of ifaces.reverse()) {
if (!interfaceList.includes(iface)) {
interfaceList.unshift(iface);
}
}
// Build the tree view
const json = [];
for (const iface of interfaceList) {
json.push(interfaces[iface]);
}
$("#tree").bstreeview({
data: json,
expandIcon: "fa fa-angle-down fa-fw",
collapseIcon: "fa fa-angle-right fa-fw",
parentsMarginLeft: "0",
indent: 2.5,
renderTreeView(json);
expandGatewayInterfaces(gateways);
});
$("#spinner").hide();
// Expand gateway interfaces by default
for (const gw of gateways) {
const div = $("#tree").find("div:contains('" + gw + "')");
div.removeClass("collapsed");
div.next("div").collapse("show");
// Change expand icon to collapse icon
div.find("i:first").removeClass("fa-angle-right").addClass("fa-angle-down");
}
});
});
function extractGateways(gateway) {
// Get all objects in gateway that has family == "inet"
const inet = gateway.find(obj => obj.family === "inet");
// Get first object in gateway that has family == "inet6"
const inet6 = gateway.find(obj => obj.family === "inet6");
// Create a set of the gateways when they are found
const gateways = new Set();
if (inet !== undefined) {
gateways.add(inet.gateway);
}
if (inet6 !== undefined) {
gateways.add(inet6.gateway);
}
return gateways;
}
function processInterfaces(interfacesData, gateways, intl) {
const interfaces = {};
const masterInterfaces = {};
// For each interface in data.interface, create a new object and push it to json
for (const iface of interfacesData) {
const obj = createInterfaceObject({ iface, gateways, intl, masterInterfaces, interfacesData });
interfaces[iface.name] = obj;
}
return { interfaces, masterInterfaces };
}
function createInterfaceObject({ iface, gateways, intl, masterInterfaces, interfacesData }) {
const carrierColor = iface.carrier ? "text-green" : "text-red";
const stateText = determineStateText(iface);
const status = `<span class="${carrierColor}">${stateText}</span>`;
const master = findMasterInterface({
iface,
masterInterfaces,
interfacesData,
});
// Show an icon for indenting slave interfaces
const icon = master === null ? "" : '<span class="child-interface-icon">&nbsp;&rdca;</span> ';
const obj = {
text: `${icon + iface.name} - ${status}`,
class: gateways.has(iface.name) ? "text-bold" : null,
icon: master === null ? "fa fa-network-wired fa-fw" : "",
nodes: [],
};
addMasterDetails(obj, master);
addSpeedDetails(obj, iface, intl);
addTypeDetails(obj, iface);
addFlagsDetails(obj, iface);
addHardwareAddressDetails(obj, iface);
addAddressDetails(obj, iface, intl);
addStatisticsDetails(obj, iface, intl);
addFurtherDetails(obj, iface, intl);
return obj;
}
function determineStateText(iface) {
let stateText = iface.state.toUpperCase();
if (stateText === "UNKNOWN" && iface.flags !== undefined && iface.flags.length > 0) {
// WireGuards, etc. -> the typo is intentional
if (iface.flags.includes("pointopoint")) {
stateText = "P2P";
// Loopback interfaces
} else if (iface.flags.includes("loopback")) {
stateText = "LOOPBACK";
}
}
return stateText;
}
function findMasterInterface({ iface, masterInterfaces, interfacesData }) {
if (iface.master === undefined) return null;
const masterObj = interfacesData.find(obj => obj.index === iface.master);
if (!masterObj) return null;
const masterName = masterObj.name;
if (masterName in masterInterfaces) {
masterInterfaces[masterName].push(iface.name);
} else {
masterInterfaces[masterName] = [iface.name];
}
return masterName;
}
function addMasterDetails(obj, master) {
if (master !== null) {
obj.nodes.push({
text: `Master interface: <code>${utils.escapeHtml(master)}</code>`,
icon: "fa fa-network-wired fa-fw",
});
}
}
function addSpeedDetails(obj, iface, intl) {
if (iface.speed) {
obj.nodes.push({
text: `Speed: ${intl.format(iface.speed)} Mbit/s`,
icon: "fa fa-tachometer-alt fa-fw",
});
}
}
function addTypeDetails(obj, iface) {
if (iface.type !== undefined) {
obj.nodes.push({
text: `Type: ${utils.escapeHtml(iface.type)}`,
icon: "fa fa-network-wired fa-fw",
});
}
}
function addFlagsDetails(obj, iface) {
if (iface.flags !== undefined && iface.flags.length > 0) {
obj.nodes.push({
text: `Flags: ${utils.escapeHtml(iface.flags.join(", "))}`,
icon: "fa fa-flag fa-fw",
});
}
}
function addHardwareAddressDetails(obj, iface) {
if (iface.address === undefined) return;
const extra =
iface.perm_address !== undefined && iface.perm_address !== iface.address
? ` (permanent: <code>${utils.escapeHtml(iface.perm_address)}</code>)`
: "";
obj.nodes.push({
text: `Hardware address: <code>${utils.escapeHtml(iface.address)}</code>${extra}`,
icon: "fa fa-map-marker-alt fa-fw",
});
}
function addAddressDetails(obj, iface, intl) {
if (iface.addresses === undefined) return;
const count = iface.addresses.length;
const label = count === 1 ? " address" : " addresses";
const text = `${count + label} connected to interface`;
const addresses = {
text,
icon: "fa fa-map-marker-alt fa-fw",
nodes: [],
};
for (const addr of iface.addresses) {
const jaddr = createAddressNode(addr, intl);
addresses.nodes.push(jaddr);
}
obj.nodes.push(addresses);
}
function createAddressNode(addr, intl) {
let extraAddr = addr.prefixlen !== undefined ? ` / <code>${addr.prefixlen}</code>` : "";
if (addr.address_type !== undefined) {
let familyextra = "";
if (addr.family === "inet") {
familyextra = "IPv4 ";
} else if (addr.family === "inet6") {
familyextra = "IPv6 ";
}
extraAddr += ` (${familyextra}${utils.escapeHtml(addr.address_type)})`;
}
const family = addr.family !== undefined ? `${addr.family}</code> <code>` : "";
const jaddr = {
text: `Address: <code>${family}${utils.escapeHtml(addr.address)}</code>${extraAddr}`,
icon: "fa fa-map-marker-alt fa-fw",
nodes: [],
};
if (addr.local !== undefined) {
jaddr.nodes.push({
text: `Local: <code>${utils.escapeHtml(addr.local)}</code>`,
icon: "fa fa-map-marker-alt fa-fw",
});
}
if (addr.broadcast !== undefined) {
jaddr.nodes.push({
text: `Broadcast: <code>${utils.escapeHtml(addr.broadcast)}</code>`,
icon: "fa fa-map-marker-alt fa-fw",
});
}
if (addr.scope !== undefined) {
jaddr.nodes.push({
text: `Scope: ${utils.escapeHtml(addr.scope)}`,
icon: "fa fa-map-marker-alt fa-fw",
});
}
if (addr.flags !== undefined && addr.flags.length > 0) {
jaddr.nodes.push({
text: `Flags: ${utils.escapeHtml(addr.flags.join(", "))}`,
icon: "fa fa-map-marker-alt fa-fw",
});
}
if (addr.prefered !== undefined) {
const pref = addr.prefered === 4_294_967_295 ? "forever" : `${intl.format(addr.prefered)} s`;
jaddr.nodes.push({
text: `Preferred lifetime: ${pref}`,
icon: "fa fa-clock fa-fw",
});
}
if (addr.valid !== undefined) {
const valid = addr.valid === 4_294_967_295 ? "forever" : `${intl.format(addr.valid)} s`;
jaddr.nodes.push({
text: `Valid lifetime: ${valid}`,
icon: "fa fa-clock fa-fw",
});
}
if (addr.cstamp !== undefined || addr.tstamp !== undefined) {
const formatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "short",
timeStyle: "short",
});
if (addr.cstamp !== undefined) {
jaddr.nodes.push({
text: `Created: ${formatter.format(new Date(addr.cstamp * 1000))}`,
icon: "fa fa-clock fa-fw",
});
}
if (addr.tstamp !== undefined) {
jaddr.nodes.push({
text: `Last updated: ${formatter.format(new Date(addr.tstamp * 1000))}`,
icon: "fa fa-clock fa-fw",
});
}
}
return jaddr;
}
function addStatisticsDetails(obj, iface, intl) {
if (iface.stats === undefined) return;
const stats = {
text: "Statistics",
icon: "fa fa-chart-line fa-fw",
expanded: false,
nodes: [],
};
if (iface.stats.rx_bytes !== undefined) {
stats.nodes.push({
text: `RX bytes: ${intl.format(iface.stats.rx_bytes.value)} ${iface.stats.rx_bytes.unit}`,
icon: "fa fa-download fa-fw",
});
}
if (iface.stats.tx_bytes !== undefined) {
stats.nodes.push({
text: `TX bytes: ${intl.format(iface.stats.tx_bytes.value)} ${iface.stats.tx_bytes.unit}`,
icon: "fa fa-upload fa-fw",
});
}
if (iface.stats.rx_packets !== undefined) {
stats.nodes.push({
text: `RX packets: ${intl.format(iface.stats.rx_packets)}`,
icon: "fa fa-download fa-fw",
});
}
if (iface.stats.rx_errors !== undefined && iface.stats.rx_packets) {
const rxErrorPercentage = ((iface.stats.rx_errors / iface.stats.rx_packets) * 100).toFixed(1);
stats.nodes.push({
text: `RX errors: ${intl.format(iface.stats.rx_errors)} (${rxErrorPercentage}%)`,
icon: "fa fa-download fa-fw",
});
}
if (iface.stats.rx_dropped !== undefined && iface.stats.rx_packets) {
const rxDropped = iface.stats.rx_dropped;
const rxPackets = iface.stats.rx_packets;
const rxDroppedPercentage = ((rxDropped / rxPackets) * 100).toFixed(1);
stats.nodes.push({
text: `RX dropped: ${intl.format(rxDropped)} (${rxDroppedPercentage}%)`,
icon: "fa fa-download fa-fw",
});
}
if (iface.stats.tx_packets !== undefined) {
stats.nodes.push({
text: `TX packets: ${intl.format(iface.stats.tx_packets)}`,
icon: "fa fa-upload fa-fw",
});
}
if (iface.stats.tx_errors !== undefined && iface.stats.tx_packets) {
const txErrorPercentage = ((iface.stats.tx_errors / iface.stats.tx_packets) * 100).toFixed(1);
stats.nodes.push({
text: `TX errors: ${intl.format(iface.stats.tx_errors)} (${txErrorPercentage}%)`,
icon: "fa fa-upload fa-fw",
});
}
if (iface.stats.tx_dropped !== undefined && iface.stats.tx_packets) {
const txDropped = iface.stats.tx_dropped;
const txPackets = iface.stats.tx_packets;
const txDroppedPercentage = ((txDropped / txPackets) * 100).toFixed(1);
stats.nodes.push({
text: `TX dropped: ${intl.format(txDropped)} (${txDroppedPercentage}%)`,
icon: "fa fa-upload fa-fw",
});
}
if (iface.stats.multicast !== undefined) {
stats.nodes.push({
text: `Multicast: ${intl.format(iface.stats.multicast)}`,
icon: "fa fa-broadcast-tower fa-fw",
});
}
if (iface.stats.collisions !== undefined) {
stats.nodes.push({
text: `Collisions: ${intl.format(iface.stats.collisions)}`,
icon: "fa fa-exchange-alt fa-fw",
});
}
obj.nodes.push(stats);
}
function addFurtherDetails(obj, iface, intl) {
const furtherDetails = {
text: "Further details",
icon: "fa fa-info-circle fa-fw",
expanded: false,
nodes: [],
};
const carrierStatus = iface.carrier
? '<span class="text-green">Connected</span>'
: '<span class="text-red">Disconnected</span>';
furtherDetails.nodes.push(
{
text: `Carrier: ${carrierStatus}`,
icon: "fa fa-link fa-fw",
},
{
text: `State: ${utils.escapeHtml(iface.state.toUpperCase())}`,
icon: "fa fa-server fa-fw",
}
);
if (iface.parent_dev_name !== undefined) {
const extra =
iface.parent_dev_bus_name !== undefined
? ` @ ${utils.escapeHtml(iface.parent_dev_bus_name)}`
: "";
furtherDetails.nodes.push({
text: `Parent device: <code>${utils.escapeHtml(iface.parent_dev_name)}${extra}</code>`,
icon: "fa fa-network-wired fa-fw",
});
}
if (iface.carrier_changes !== undefined) {
furtherDetails.nodes.push({
text: `Carrier changes: ${intl.format(iface.carrier_changes)}`,
icon: "fa fa-exchange-alt fa-fw",
});
}
if (iface.broadcast) {
furtherDetails.nodes.push({
text: `Broadcast: <code>${utils.escapeHtml(iface.broadcast)}</code>`,
icon: "fa fa-broadcast-tower fa-fw",
});
}
if (iface.mtu) {
let extra = "";
if (iface.min_mtu !== undefined && iface.max_mtu !== undefined) {
const minMtu = intl.format(iface.min_mtu);
const maxMtu = intl.format(iface.max_mtu);
extra += ` (min: ${minMtu} bytes, max: ${maxMtu} bytes)`;
}
furtherDetails.nodes.push({
text: `MTU: ${intl.format(iface.mtu)} bytes${extra}`,
icon: "fa fa-arrows-alt-h fa-fw",
});
}
if (iface.txqlen) {
furtherDetails.nodes.push({
text: `TX queue length: ${intl.format(iface.txqlen)}`,
icon: "fa fa-file-upload fa-fw",
});
}
if (iface.promiscuity !== undefined) {
furtherDetails.nodes.push({
text: `Promiscuity mode: ${iface.promiscuity ? "Yes" : "No"}`,
icon: "fa fa-eye fa-fw",
});
}
if (iface.qdisc !== undefined) {
furtherDetails.nodes.push({
text: `Scheduler: ${utils.escapeHtml(iface.qdisc)}`,
icon: "fa fa-network-wired fa-fw",
});
}
if (furtherDetails.nodes.length > 0) {
obj.nodes.push(furtherDetails);
}
}
function sortInterfaces(interfaces, masterInterfaces) {
// Sort interfaces based on masterInterfaces. If an item is found in
// masterInterfaces, it should be placed after the master interface
const ifaces = Object.keys(interfaces);
const interfaceList = Object.keys(masterInterfaces);
// Add slave interfaces next to master interfaces
for (const master of interfaceList) {
if (master in masterInterfaces) {
for (const slave of masterInterfaces[master]) {
ifaces.splice(ifaces.indexOf(slave), 1);
interfaceList.splice(interfaceList.indexOf(master) + 1, 0, slave);
}
}
}
// Add interfaces that are not slaves at the top of the list (in reverse order)
for (const iface of ifaces.reverse()) {
if (!interfaceList.includes(iface)) {
interfaceList.unshift(iface);
}
}
return interfaceList;
}
function renderTreeView(json) {
$("#tree").bstreeview({
data: json,
expandIcon: "fa fa-angle-down fa-fw",
collapseIcon: "fa fa-angle-right fa-fw",
parentsMarginLeft: "0",
indent: 2.5,
});
document.getElementById("spinner").classList.add("d-none");
}
function expandGatewayInterfaces(gateways) {
// Expand gateway interfaces by default
const tree = document.getElementById("tree");
if (!tree) return;
for (const gw of gateways) {
// Find all divs containing the gateway name
const divs = tree.querySelectorAll("div");
for (const div of divs) {
if (!div.textContent.includes(gw)) continue;
div.classList.remove("collapsed");
const nextDiv = div.nextElementSibling;
if (nextDiv) $(nextDiv).collapse("show");
// Change expand icon to collapse icon
const icon = div.querySelector("i");
if (!icon) continue;
icon.classList.remove("fa-angle-right");
icon.classList.add("fa-angle-down");
}
}
}

View File

@@ -38,6 +38,7 @@ $.extend($.fn.dataTableExt.oSort, {
cidr = m[3].split("/");
if (cidr.length === 2) {
m.pop();
// eslint-disable-next-line unicorn/prefer-spread
m = m.concat(cidr);
}

View File

@@ -64,12 +64,9 @@ function wrongPassword(isError = false, isSuccess = false, data = null) {
// Only show the forgot password box if the error is NOT caused by an
// invalid TOTP token and this is no error response (= password is wrong)
if (!isErrorResponse && !isInvalidTOTP) {
$("#forgot-pw-box")
.removeClass("box-info")
.removeClass("collapsed-box")
.addClass("box-danger");
$("#forgot-pw-box .box-body").show();
$("#forgot-pw-toggle-icon").removeClass("fa-plus").addClass("fa-minus");
const forgotPwBox = document.getElementById("forgot-pw-box");
forgotPwBox.classList.replace("box-info", "box-danger");
utils.toggleBoxCollapse(forgotPwBox, true);
}
return;
@@ -85,9 +82,9 @@ function wrongPassword(isError = false, isSuccess = false, data = null) {
}
$("#invalid2fa-box").addClass("hidden");
$("#forgot-pw-box").addClass("box-info").addClass("collapsed-box").removeClass("box-danger");
$("#forgot-pw-box .box-body").hide();
$("#forgot-pw-toggle-icon").removeClass("fa-minus").addClass("fa-plus");
const forgotPwBox = document.getElementById("forgot-pw-box");
forgotPwBox.classList.replace("box-danger", "box-info");
utils.toggleBoxCollapse(forgotPwBox, false);
}
function doLogin(password) {

View File

@@ -32,7 +32,7 @@ $(() => {
{ data: null, visible: true, width: "15px" },
{ data: "timestamp", width: "8%", render: utils.renderTimestamp },
{ data: "type", width: "8%" },
{ data: "html", orderable: false, render: utils.htmlPass },
{ data: "html", orderable: false, render: (data, _type) => data },
{ data: null, width: "22px", orderable: false },
],
columnDefs: [

View File

@@ -28,6 +28,14 @@ const filters = [
"reply",
"dnssec",
];
let doDNSSEC = false;
// Check if pihole is validiting DNSSEC
function getDnssecConfig() {
$.getJSON(document.body.dataset.apiurl + "/config/dns/dnssec", data => {
doDNSSEC = data.config.dns.dnssec;
});
}
function initDateRangePicker() {
$("#querytime").daterangepicker(
@@ -345,7 +353,7 @@ function formatInfo(data) {
if (dnssec.color !== "") {
dnssecInfo =
divStart +
'DNSSEC status:&nbsp&nbsp;<strong><span class="' +
'DNSSEC status:&nbsp;&nbsp;<strong><span class="' +
dnssec.color +
'">' +
dnssec.text +
@@ -362,7 +370,7 @@ function formatInfo(data) {
let replyInfo = "";
replyInfo =
data.reply.type !== "UNKNOWN"
? divStart + "Reply:&nbsp&nbsp;" + data.reply.type + "</div>"
? divStart + "Reply:&nbsp;&nbsp;" + data.reply.type + "</div>"
: divStart + "Reply:&nbsp;&nbsp;No reply received</div>";
// Show extended DNS error if applicable
@@ -480,6 +488,9 @@ function liveUpdate() {
}
$(() => {
// Do we want to show DNSSEC icons?
getDnssecConfig();
// Do we want to filter queries?
const GETDict = utils.parseQueryString();
@@ -561,11 +572,13 @@ $(() => {
utils.stateSaveCallback("query_log_table", data);
},
stateLoadCallback() {
return utils.stateLoadCallback("query_log_table");
const state = utils.stateLoadCallback("query_log_table");
// Default to 25 entries if "All" was previously selected
if (state) state.length = state.length === -1 ? 25 : state.length;
return state;
},
rowCallback(row, data) {
const querystatus = parseQueryStatus(data);
const dnssec = parseDNSSEC(data);
if (querystatus.icon !== false) {
$("td:eq(1)", row).html(
@@ -589,14 +602,17 @@ $(() => {
// Prefix colored DNSSEC icon to domain text
let dnssecIcon = "";
dnssecIcon =
'<i class="mr-2 fa fa-fw ' +
dnssec.icon +
" " +
dnssec.color +
'" title="DNSSEC: ' +
dnssec.text +
'"></i>';
if (doDNSSEC === true) {
const dnssec = parseDNSSEC(data);
dnssecIcon =
'<i class="mr-2 fa fa-fw ' +
dnssec.icon +
" " +
dnssec.color +
'" title="DNSSEC: ' +
dnssec.text +
'"></i>';
}
// Escape HTML in domain
domain = dnssecIcon + utils.escapeHtml(domain);

View File

@@ -145,11 +145,6 @@ function populateDataTable(endpoint) {
});
}
$(() => {
populateDataTable("hosts");
populateDataTable("cnameRecords");
});
function deleteRecord() {
if ($(this).attr("data-type") === "hosts") delHosts($(this).attr("data-tag"));
else delCNAME($(this).attr("data-tag"));
@@ -215,9 +210,12 @@ function delCNAME(elem) {
}
$(() => {
populateDataTable("hosts");
populateDataTable("cnameRecords");
$("#btnAdd-host").on("click", () => {
utils.disableAll();
const elem = $("#Hip").val() + " " + $("#Hdomain").val();
const elem = $("#Hip").val().trim() + " " + $("#Hdomain").val().trim();
const url = document.body.dataset.apiurl + "/config/dns/hosts/" + encodeURIComponent(elem);
utils.showAlert("info", "", "Adding DNS record...", elem);
$.ajax({
@@ -241,7 +239,7 @@ $(() => {
$("#btnAdd-cname").on("click", () => {
utils.disableAll();
let elem = $("#Cdomain").val() + "," + $("#Ctarget").val();
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

View File

@@ -5,7 +5,7 @@
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
/* global applyCheckboxRadioStyle:false, setConfigValues: false, apiFailure: false */
/* global utils:false, applyCheckboxRadioStyle:false, setConfigValues: false, apiFailure: false */
"use strict";
@@ -94,6 +94,12 @@ function fillDNSupstreams(value, servers) {
// Initialize textfield
updateDNSserversTextfield(value.value, customServers);
// Expand the box if there are custom servers
if (customServers > 0) {
const customBox = document.getElementById("custom-servers-box");
utils.toggleBoxCollapse(customBox, true);
}
// Hide the loading animation
$("#dns-upstreams-overlay").hide();

View File

@@ -139,7 +139,7 @@ function setMetrics(data, prefix) {
cacheData[val.name] = val.count;
} else if (typeof val === "object") {
setMetrics(val, prefix + key + "-");
} else if (prefix === "sysinfo-dns-replies-") {
} else if (prefix === "sysinfo-dns-replies-" && data.sum !== 0) {
// Compute and display percentage of DNS replies in addition to the absolute value
const lval = val.toLocaleString();
const percent = (100 * val) / data.sum;
@@ -267,7 +267,7 @@ $(".confirm-flusharp").confirm({
title: "Confirmation required",
confirm() {
$.ajax({
url: document.body.dataset.apiurl + "/action/flush/arp",
url: document.body.dataset.apiurl + "/action/flush/network",
type: "POST",
}).fail(data => {
apiFailure(data);

View File

@@ -40,17 +40,17 @@ function formatDnsmasq(line) {
return txt;
}
function formatFTL(line, prio) {
function formatFTL(line, priority) {
// Colorize priority
let prioClass = "";
switch (prio) {
let priorityClass = "";
switch (priority) {
case "INFO": {
prioClass = "text-success";
priorityClass = "text-success";
break;
}
case "WARNING": {
prioClass = "text-warning";
priorityClass = "text-warning";
break;
}
@@ -59,44 +59,68 @@ function formatFTL(line, prio) {
case "EMERG":
case "ALERT":
case "CRIT": {
prioClass = "text-danger";
priorityClass = "text-danger";
break;
}
default:
prioClass = prio.startsWith("DEBUG") ? "text-info" : "text-muted";
priorityClass = priority.startsWith("DEBUG") ? "text-info" : "text-muted";
}
// Return formatted line
return `<span class="${prioClass}">${utils.escapeHtml(prio)}</span> ${line}`;
return `<span class="${priorityClass}">${utils.escapeHtml(priority)}</span> ${line}`;
}
let gAutoScrolling;
// Function that asks the API for new data
function getData() {
// Only update when spinner is spinning
if (!$("#feed-icon").hasClass("fa-play")) {
// Only update when the feed icon has the fa-play class
const feedIcon = document.getElementById("feed-icon");
if (!feedIcon.classList.contains("fa-play")) {
utils.setTimer(getData, REFRESH_INTERVAL.logs);
return;
}
const GETDict = utils.parseQueryString();
if (!("file" in GETDict)) {
globalThis.location.href += "?file=dnsmasq";
const queryParams = utils.parseQueryString();
const outputElement = document.getElementById("output");
const allowedFileParams = ["dnsmasq", "ftl", "webserver"];
// Check if file parameter exists
if (!queryParams.file) {
// Add default file parameter and redirect
const url = new URL(globalThis.location.href);
url.searchParams.set("file", "dnsmasq");
globalThis.location.href = url.toString();
return;
}
$.ajax({
url: document.body.dataset.apiurl + "/logs/" + GETDict.file + "?nextID=" + nextID,
timeout: 5000,
// Validate that file parameter is one of the allowed values
if (!allowedFileParams.includes(queryParams.file)) {
const errorMessage = `Invalid file parameter: ${queryParams.file}. Allowed values are: ${allowedFileParams.join(", ")}`;
outputElement.innerHTML = `<div><em class="text-danger">*** Error: ${errorMessage} ***</em></div>`;
return;
}
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute("content");
const url = `${document.body.dataset.apiurl}/logs/${queryParams.file}?nextID=${nextID}`;
fetch(url, {
method: "GET",
headers: {
"X-CSRF-TOKEN": csrfToken,
},
})
.done(data => {
.then(response => (response.ok ? response.json() : apiFailure(response)))
.then(data => {
// Set filename
document.getElementById("filename").textContent = data.file;
// Check if we have a new PID -> FTL was restarted
if (lastPID !== data.pid) {
if (lastPID !== -1) {
$("#output").append("<div><i class='text-danger'>*** FTL restarted ***</i></div>");
outputElement.innerHTML +=
'<div><em class="text-danger">*** FTL restarted ***</em></div>';
}
// Remember PID
@@ -111,119 +135,167 @@ function getData() {
// Set placeholder text if log file is empty and we have no new lines
if (data.log.length === 0) {
if (nextID === 0) {
$("#output").html("<div><em>*** Log file is empty ***</em></div>");
outputElement.innerHTML = "<div><em>*** Log file is empty ***</em></div>";
}
utils.setTimer(getData, REFRESH_INTERVAL.logs);
return;
}
// Create a document fragment to batch the DOM updates
const fragment = document.createDocumentFragment();
// We have new lines
if (markUpdates && nextID > 0) {
// Add red fading out background to new lines
$("#output").append('<hr class="hr-small">').children(":last").fadeOut(2000);
const hr = document.createElement("hr");
hr.className = "hr-small fade-2s";
fragment.append(hr);
}
// Limit output to <maxlines> lines
// Check if adding these new lines would exceed maxlines
const totalAfterAdding =
outputElement.children.length + data.log.length + (markUpdates && nextID > 0 ? 1 : 0);
// If we'll exceed maxlines, remove old elements first
if (totalAfterAdding > maxlines) {
const elementsToRemove = totalAfterAdding - maxlines;
const elements = [...outputElement.children];
const elementsToKeep = elements.slice(elementsToRemove);
outputElement.replaceChildren(...elementsToKeep);
}
for (const line of data.log) {
// Escape HTML
line.message = utils.escapeHtml(line.message);
// Format line if applicable
if (GETDict.file === "dnsmasq") line.message = formatDnsmasq(line.message);
else if (GETDict.file === "ftl") line.message = formatFTL(line.message, line.prio);
// Add new line to output
$("#output").append(
'<div class="log-entry"><span class="text-muted">' +
moment(1000 * line.timestamp).format("YYYY-MM-DD HH:mm:ss.SSS") +
"</span> " +
line.message +
"</div>"
);
if (fadeIn) {
//$(".left-line:last").fadeOut(2000);
$("#output").children(":last").hide().fadeIn("fast");
// Format line if applicable
if (queryParams.file === "dnsmasq") {
line.message = formatDnsmasq(line.message);
} else if (queryParams.file === "ftl") {
line.message = formatFTL(line.message, line.prio);
}
// Create and add new log entry to fragment
const logEntry = document.createElement("div");
const logEntryDate = moment(1000 * line.timestamp).format("YYYY-MM-DD HH:mm:ss.SSS");
logEntry.className = `log-entry${fadeIn ? " hidden-entry" : ""}`;
logEntry.innerHTML = `<span class="text-muted">${logEntryDate}</span> ${line.message}`;
fragment.append(logEntry);
}
// Limit output to <maxlines> lines
const lines = $("#output").val().split("\n");
if (lines.length > maxlines) {
lines.splice(0, lines.length - maxlines);
$("#output").val(lines.join("\n"));
// Append all new elements at once
outputElement.append(fragment);
if (fadeIn) {
// Fade in the new log entries
const newEntries = outputElement.querySelectorAll(".hidden-entry");
for (const entry of newEntries) {
entry.classList.add("fade-in-transition");
}
// Force a reflow once before changing opacity
void outputElement.offsetWidth; // eslint-disable-line no-void
requestAnimationFrame(() => {
for (const entry of newEntries) {
entry.classList.remove("hidden-entry");
entry.style.opacity = 1;
}
});
// Clean up after animation completes
setTimeout(() => {
for (const entry of newEntries) {
entry.classList.remove("fade-in-transition");
}
}, 200);
}
// Scroll to bottom of output if we are already at the bottom
if (gAutoScrolling) {
// Auto-scrolling is enabled
$("#output").scrollTop($("#output")[0].scrollHeight);
requestAnimationFrame(() => {
outputElement.scrollTop = outputElement.scrollHeight;
});
}
// Update nextID
nextID = data.nextID;
// Set filename
$("#filename").text(data.file);
utils.setTimer(getData, REFRESH_INTERVAL.logs);
})
.fail(data => {
apiFailure(data);
.catch(error => {
apiFailure(error);
utils.setTimer(getData, 5 * REFRESH_INTERVAL.logs);
});
}
gAutoScrolling = true;
$("#output").on("scroll", () => {
// Check if we are at the bottom of the output
//
// - $("#output")[0].scrollHeight: This gets the entire height of the content
// of the "output" element, including the part that is not visible due to
// scrolling.
// - $("#output").innerHeight(): This gets the inner height of the "output"
// element, which is the visible part of the content.
// - $("#output").scrollTop(): This gets the number of pixels that the content
// of the "output" element is scrolled vertically from the top.
//
// By subtracting the inner height and the scroll top from the scroll height,
// you get the distance from the bottom of the scrollable area.
const bottom =
$("#output")[0].scrollHeight - $("#output").innerHeight() - $("#output").scrollTop();
// Add a tolerance of four line heights
const tolerance = 4 * Number.parseFloat($("#output").css("line-height"));
if (bottom <= tolerance) {
// Auto-scrolling is enabled
gAutoScrolling = true;
$("#autoscrolling").addClass("fa-check");
$("#autoscrolling").removeClass("fa-xmark");
} else {
// Auto-scrolling is disabled
gAutoScrolling = false;
$("#autoscrolling").addClass("fa-xmark");
$("#autoscrolling").removeClass("fa-check");
}
});
document.getElementById("output").addEventListener(
"scroll",
event => {
const output = event.currentTarget;
// Check if we are at the bottom of the output
//
// - output.scrollHeight: This gets the entire height of the content
// of the "output" element, including the part that is not visible due to
// scrolling.
// - output.clientHeight: This gets the inner height of the "output"
// element, which is the visible part of the content.
// - output.scrollTop: This gets the number of pixels that the content
// of the "output" element is scrolled vertically from the top.
//
// By subtracting the inner height and the scroll top from the scroll height,
// you get the distance from the bottom of the scrollable area.
const { scrollHeight, clientHeight, scrollTop } = output;
// Add a tolerance of four line heights
const tolerance = 4 * Number.parseFloat(getComputedStyle(output).lineHeight);
// Determine if the output is scrolled to the bottom within the tolerance
const isAtBottom = scrollHeight - clientHeight - scrollTop <= tolerance;
gAutoScrolling = isAtBottom;
const autoScrollingElement = document.getElementById("autoscrolling");
if (isAtBottom) {
autoScrollingElement.classList.add("fa-check");
autoScrollingElement.classList.remove("fa-xmark");
} else {
autoScrollingElement.classList.add("fa-xmark");
autoScrollingElement.classList.remove("fa-check");
}
},
{ passive: true }
);
$(() => {
getData();
// Clicking on the element with class "fa-spinner" will toggle the play/pause state
$("#live-feed").on("click", function () {
if ($("#feed-icon").hasClass("fa-play")) {
// Toggle button color
$("#feed-icon").addClass("fa-pause");
$("#feed-icon").removeClass("fa-fade");
$("#feed-icon").removeClass("fa-play");
$(this).addClass("btn-danger");
$(this).removeClass("btn-success");
$("#title").text("Paused");
const liveFeed = document.getElementById("live-feed");
const feedIcon = document.getElementById("feed-icon");
const title = document.getElementById("title");
// Clicking on the element with ID "live-feed" will toggle the play/pause state
liveFeed.addEventListener("click", event => {
// Determine current state based on whether feedIcon has the "fa-play" class
const isPlaying = feedIcon.classList.contains("fa-play");
if (isPlaying) {
feedIcon.classList.add("fa-pause");
feedIcon.classList.remove("fa-fade", "fa-play");
event.currentTarget.classList.add("btn-danger");
event.currentTarget.classList.remove("btn-success");
title.textContent = "Paused";
} else {
// Toggle button color
$("#feed-icon").addClass("fa-play");
$("#feed-icon").addClass("fa-fade");
$("#feed-icon").removeClass("fa-pause");
$(this).addClass("btn-success");
$(this).removeClass("btn-danger");
$("#title").text("Live");
feedIcon.classList.add("fa-play", "fa-fade");
event.currentTarget.classList.add("btn-success");
event.currentTarget.classList.remove("btn-danger");
title.textContent = "Live";
}
});
});

View File

@@ -418,33 +418,6 @@ function checkMessages() {
});
}
// Show only the appropriate delete buttons in datatables
function changeBulkDeleteStates(table) {
const allRows = table.rows({ filter: "applied" }).data().length;
const pageLength = table.page.len();
const selectedRows = table.rows(".selected").data().length;
if (selectedRows === 0) {
// Nothing selected
$(".selectAll").removeClass("hidden");
$(".selectMore").addClass("hidden");
$(".removeAll").addClass("hidden");
$(".deleteSelected").addClass("hidden");
} else if (selectedRows >= pageLength || selectedRows === allRows) {
// Whole page is selected (or all available messages were selected)
$(".selectAll").addClass("hidden");
$(".selectMore").addClass("hidden");
$(".removeAll").removeClass("hidden");
$(".deleteSelected").removeClass("hidden");
} else {
// Some rows are selected, but not all
$(".selectAll").addClass("hidden");
$(".selectMore").removeClass("hidden");
$(".removeAll").addClass("hidden");
$(".deleteSelected").removeClass("hidden");
}
}
function doLogout(url) {
$.ajax({
url: document.body.dataset.apiurl + "/auth",
@@ -474,34 +447,35 @@ function renderTimespan(data, type) {
return data;
}
function htmlPass(data, _type) {
return data;
}
// Show only the appropriate buttons
// Show only the appropriate delete buttons in datatables
function changeTableButtonStates(table) {
const selectAllElements = document.querySelectorAll(".selectAll");
const selectMoreElements = document.querySelectorAll(".selectMore");
const removeAllElements = document.querySelectorAll(".removeAll");
const deleteSelectedElements = document.querySelectorAll(".deleteSelected");
const allRows = table.rows({ filter: "applied" }).data().length;
const pageLength = table.page.len();
const selectedRows = table.rows(".selected").data().length;
if (selectedRows === 0) {
// Nothing selected
$(".selectAll").removeClass("hidden");
$(".selectMore").addClass("hidden");
$(".removeAll").addClass("hidden");
$(".deleteSelected").addClass("hidden");
for (const el of selectAllElements) el.classList.remove("hidden");
for (const el of selectMoreElements) el.classList.add("hidden");
for (const el of removeAllElements) el.classList.add("hidden");
for (const el of deleteSelectedElements) el.classList.add("hidden");
} else if (selectedRows >= pageLength || selectedRows === allRows) {
// Whole page is selected (or all available messages were selected)
$(".selectAll").addClass("hidden");
$(".selectMore").addClass("hidden");
$(".removeAll").removeClass("hidden");
$(".deleteSelected").removeClass("hidden");
for (const el of selectAllElements) el.classList.add("hidden");
for (const el of selectMoreElements) el.classList.add("hidden");
for (const el of removeAllElements) el.classList.remove("hidden");
for (const el of deleteSelectedElements) el.classList.remove("hidden");
} else {
// Some rows are selected, but not all
$(".selectAll").addClass("hidden");
$(".selectMore").removeClass("hidden");
$(".removeAll").addClass("hidden");
$(".deleteSelected").removeClass("hidden");
for (const el of selectAllElements) el.classList.add("hidden");
for (const el of selectMoreElements) el.classList.remove("hidden");
for (const el of removeAllElements) el.classList.add("hidden");
for (const el of deleteSelectedElements) el.classList.remove("hidden");
}
}
@@ -517,26 +491,19 @@ function parseQueryString() {
return Object.fromEntries(params.entries());
}
// https://stackoverflow.com/q/21647928
function hexEncode(string) {
let result = "";
for (let i = 0; i < string.length; i++) {
const hex = string.codePointAt(i).toString(16);
result += ("000" + hex).slice(-4);
}
function hexEncode(text) {
if (typeof text !== "string" || text.length === 0) return "";
return result;
return [...text].map(char => char.codePointAt(0).toString(16).padStart(4, "0")).join("");
}
// https://stackoverflow.com/q/21647928
function hexDecode(string) {
const hexes = string.match(/.{1,4}/g) || [];
let back = "";
for (const hex of hexes) {
back += String.fromCodePoint(Number.parseInt(hex, 16));
}
function hexDecode(text) {
if (typeof text !== "string" || text.length === 0) return "";
return back;
const hexes = text.match(/.{1,4}/g);
if (!hexes || hexes.length === 0) return "";
return hexes.map(hex => String.fromCodePoint(Number.parseInt(hex, 16))).join("");
}
function listsAlert(type, items, data) {
@@ -680,6 +647,30 @@ function setInter(func, interval) {
globalThis.setTimeout(setInter, interval, func, interval);
}
/**
* Toggle or set the collapse state of a box element
* @param {HTMLElement} box - The box element
* @param {boolean} [expand=true] - Whether to expand (true) or collapse (false) the box
*/
// Not using the AdminLTE API so that the expansion is not animated
// Otherwise, we could use `$(customBox).boxWidget("expand")`
function toggleBoxCollapse(box, expand = true) {
if (!box) return;
const icon = box.querySelector(".btn-box-tool > i");
const body = box.querySelector(".box-body");
if (expand) {
box.classList.remove("collapsed-box");
if (icon) icon.classList.replace("fa-plus", "fa-minus");
if (body) body.style = "";
} else {
box.classList.add("collapsed-box");
if (icon) icon.classList.replace("fa-minus", "fa-plus");
if (body) body.style.display = "none";
}
}
globalThis.utils = (function () {
return {
escapeHtml,
@@ -702,11 +693,9 @@ globalThis.utils = (function () {
toPercent,
colorBar,
checkMessages,
changeBulkDeleteStates,
doLogout,
renderTimestamp,
renderTimespan,
htmlPass,
changeTableButtonStates,
getCSSval,
parseQueryString,
@@ -716,5 +705,6 @@ globalThis.utils = (function () {
loadingOverlay,
setTimer,
setInter,
toggleBoxCollapse,
};
})();

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<!--
* Pi-hole: A black hole for Internet advertisements
* (c) 2017 Pi-hole, LLC (https://pi-hole.net)
@@ -96,27 +96,25 @@ is_authenticated = mg.request_info.is_authenticated
<!-- Common styles -->
<link rel="stylesheet" href="<?=pihole.fileversion('vendor/bootstrap/css/bootstrap.min.css')?>">
<link rel="stylesheet" href="<?=pihole.fileversion('vendor/icheck/icheck-bootstrap.min.css')?>">
<link rel="stylesheet" href="<?=pihole.fileversion('vendor/animate/animate.min.css')?>">
<link rel="stylesheet" href="<?=pihole.fileversion('vendor/bstreeview/bstreeview.min.css')?>">
<link rel="stylesheet" href="<?=pihole.fileversion('vendor/font-awesome/css/all.min.css')?>">
<link rel="stylesheet" href="<?=pihole.fileversion('vendor/nprogress/nprogress.min.css')?>">
<link rel="stylesheet" href="<?=pihole.fileversion('vendor/adminLTE/AdminLTE.min.css')?>">
<? if startsWith(scriptname, 'groups') then
-- Group management styles
?>
<link rel="stylesheet" href="<?=pihole.fileversion('vendor/bootstrap-select/bootstrap-select.min.css')?>">
<? end ?>
<? if is_authenticated then ?>
<? if startsWith(scriptname, 'groups') then ?>
<link rel="stylesheet" href="<?=pihole.fileversion('vendor/bootstrap-select/bootstrap-select.min.css')?>">
<? end ?>
<link rel="stylesheet" href="<?=pihole.fileversion('vendor/icheck/icheck-bootstrap.min.css')?>">
<link rel="stylesheet" href="<?=pihole.fileversion('vendor/bstreeview/bstreeview.min.css')?>">
<link rel="stylesheet" href="<?=pihole.fileversion('vendor/datatables/datatables.min.css')?>">
<link rel="stylesheet" href="<?=pihole.fileversion('vendor/datatables-buttons/datatables.buttons.min.css')?>">
<link rel="stylesheet" href="<?=pihole.fileversion('vendor/datatables-select/datatables.select.min.css')?>">
<link rel="stylesheet" href="<?=pihole.fileversion('vendor/daterangepicker/daterangepicker.min.css')?>">
<link rel="stylesheet" href="<?=pihole.fileversion('vendor/bootstrap-toggle/bootstrap-toggle.min.css')?>">
<? end ?>
<link rel="stylesheet" href="<?=pihole.fileversion('vendor/waitMe-js/waitMe.min.css')?>">
<link rel="stylesheet" href="<?=pihole.fileversion('vendor/select2/select2.min.css')?>">
<link rel="stylesheet" href="<?=pihole.fileversion('vendor/adminLTE/AdminLTE.min.css')?>">
<? end ?>
<!-- Theme styles (<?= theme.name ?>) -->
<link rel="stylesheet" href="<?=pihole.fileversion('style/pi-hole.css')?>">
@@ -133,7 +131,5 @@ is_authenticated = mg.request_info.is_authenticated
<script src="<?=pihole.fileversion('vendor/jquery/jquery.min.js')?>"></script>
<script src="<?=pihole.fileversion('vendor/bootstrap/js/bootstrap.min.js')?>"></script>
<script src="<?=pihole.fileversion('vendor/adminLTE/adminlte.min.js')?>"></script>
<script src="<?=pihole.fileversion('vendor/bootstrap-notify/bootstrap-notify.min.js')?>"></script>
<script src="<?=pihole.fileversion('vendor/waitMe-js/modernized-waitme-min.js')?>"></script>
<script src="<?=pihole.fileversion('vendor/nprogress/nprogress.min.js')?>"></script>
<script src="<?=pihole.fileversion('scripts/js/utils.js')?>"></script>

View File

@@ -9,20 +9,22 @@
]]--
mg.include('header.lp','r')
?>
<script src="<?=pihole.fileversion('vendor/bootstrap-notify/bootstrap-notify.min.js')?>"></script>
<script src="<?=pihole.fileversion('vendor/select2/select2.min.js')?>"></script>
<script src="<?=pihole.fileversion('vendor/datatables/datatables.min.js')?>"></script>
<script src="<?=pihole.fileversion('vendor/datatables-select/datatables.select.min.js')?>"></script>
<script src="<?=pihole.fileversion('vendor/datatables-buttons/datatables.buttons.min.js')?>"></script>
<script src="<?=pihole.fileversion('vendor/chartjs/chart.umd.js')?>"></script>
<script src="<?=pihole.fileversion('vendor/chartjs/chart.umd.min.js')?>"></script>
<script src="<?=pihole.fileversion('vendor/chartjs-plugin-deferred/chartjs-plugin-deferred.min.js')?>"></script>
<script src="<?=pihole.fileversion('vendor/moment/moment.min.js')?>"></script>
<script src="<?=pihole.fileversion('vendor/chartjs-adapter-moment/chartjs-adapter-moment.js')?>"></script>
<script src="<?=pihole.fileversion('vendor/hammer/hammer.min.js')?>"></script> <!-- Needed for chartjs-plugin-zoom -->
<script src="<?=pihole.fileversion('vendor/chartjs-plugin-zoom/chartjs-plugin-zoom.min.js')?>"></script>
<script src="<?=pihole.fileversion('vendor/bstreeview/bstreeview.min.js')?>"></script>
<script src="<?=pihole.fileversion('vendor/waitMe-js/modernized-waitme-min.js')?>"></script>
<script src="<?=pihole.fileversion('scripts/js/logout.js')?>"></script>
</head>
<body class="<?=theme.name?> hold-transition sidebar-mini <? if pihole.boxedlayout() then ?>layout-boxed<? end ?> logged-in page-<?=pihole.format_path(mg.request_info.request_uri)?>" data-apiurl="<?=pihole.api_url()?>" data-webhome="<?=webhome?>">
<body class="<?=theme.name?> hold-transition sidebar-mini <? if pihole.boxedlayout() then ?>layout-boxed<? end ?> logged-in page-<?=pihole.format_path(scriptname)?>" data-apiurl="<?=pihole.api_url()?>" data-webhome="<?=webhome?>">
<noscript>
<!-- JS Warning -->
<div>
@@ -54,12 +56,14 @@ mg.include('header.lp','r')
</button>
<div class="navbar-custom-menu">
<ul class="nav navbar-nav">
<li<? if string.len(hostname) == 0 then ?> class="hidden"<? end ?>>
<p class="navbar-text">
<span class="hidden-xs">hostname:</span>
<code><?=hostname?></code>
</p>
</li>
<? if string.len(hostname) > 0 then ?>
<li>
<p class="navbar-text">
<span class="hidden-xs">hostname:</span>
<code><?=hostname?></code>
</p>
</li>
<? end ?>
<li class="dropdown user user-menu">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
<i class="fa fa-bars"></i>
@@ -73,7 +77,7 @@ mg.include('header.lp','r')
</p>
</li>
<!-- Menu Body -->
<li class="user-body" id="advanced-info" style="display:none;"></li>
<li class="user-body" id="advanced-info"></li>
<!-- Menu Footer -->
<li class="user-footer">
<a class="btn-link" href="https://pi-hole.net/" rel="noopener noreferrer" target="_blank">

View File

@@ -77,7 +77,7 @@
<i class="fa fa-fw menu-icon fa-shield-alt"></i>
<span>Lists
<span class="pull-right-container">
<span class="label bg-blue pull-right" id="num_lists" title="Number of subscribed and enabled lists"></span>
<span class="label label-primary pull-right" id="num_lists" title="Number of subscribed and enabled lists"></span>
<span class="label bg-yellow pull-right" id="num_gravity" title="Total number of domains subscribed by your Pi-hole"></span>
</span>
</span>

View File

@@ -13,7 +13,7 @@ mg.include('scripts/lua/header_authenticated.lp','r')
PageTitle = "All Settings"
mg.include('scripts/lua/settings_header.lp','r')
?>
<div class="row settings-level-expert d-none" id="advanced-content">
<div class="row" id="advanced-content">
<div class="overlay" id="advanced-overlay">
<i class="fa fa-sync fa-spin"></i>
</div>
@@ -28,7 +28,7 @@ mg.include('scripts/lua/settings_header.lp','r')
<!-- dynamically filled with content -->
</div>
<div class="col-sm-12 settings-level-expert d-none save-button-container">
<div class="col-sm-12 save-button-container">
<button type="button" class="btn btn-primary save-button"><i class="fa-solid fa-fw fa-floppy-disk"></i>&nbsp;Save & Apply</button>
</div>
</div>

View File

@@ -180,7 +180,7 @@ mg.include('scripts/lua/settings_header.lp','r')
<h4 class="modal-title">Enable two-factor authentication</h4>
</div>
<div class="modal-body">
<p>Use a phone app like Google Authenticator, 2FA Authenticator, FreeOTP or BitWarden, etc. to get 2FA codes when prompted during login.</p>
<p>Use a phone app like Google Authenticator, 2FA Authenticator, FreeOTP or Bitwarden, etc. to get 2FA codes when prompted during login.</p>
<p>1. Scan the QR code below with your app or enter the secret manually.</p>
<div class="text-center">
<i id="qrcode-spinner" class="fas fa-spinner fa-pulse fa-5x"></i>
@@ -192,11 +192,15 @@ mg.include('scripts/lua/settings_header.lp','r')
<p>2. Enter the 2FA code from your app below to confirm that you have set up 2FA correctly.</p>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<div id="totp_div" class="has-feedback has-error">
<div class="pwd-field form-group">
<input type="text" size="6" maxlength="6" class="form-control totp_token" id="totp_code" placeholder="">
<div id="totp_div">
<div class="pwd-field form-group has-error">
<div class="input-group">
<input type="text" size="6" maxlength="6" class="form-control totp_token" id="totp_code" placeholder="">
<div class="input-group-addon" data-toggle="tooltip" data-placement="auto" title="TOTP verification code">
<i class="fa fa-clock-rotate-left"></i>
</div>
</div>
</div>
<i class="fa-solid fa-clock-rotate-left pwd-field form-control-feedback"></i>
</div>
</div>
</div>

View File

@@ -180,13 +180,12 @@ mg.include('scripts/lua/settings_header.lp','r')
<p>Only one entry per MAC address is allowed.</p>
</div>
<div class="col-xs-12 col-md-6">
<p>Examples:
<ul>
<li><pre>00:20:e0:3b:13:af,192.168.0.123</pre> tells Pi-hole to give the machine with hardware address <code>00:20:e0:3b:13:af</code> the address <code>192.168.0.123</code><br>&nbsp;</li>
<li><pre>00:20:e0:3b:13:af,laptop</pre>tells Pi-hole to give the machine with hardware address <code>00:20:e0:3b:13:af</code> the name <code>laptop</code><br>&nbsp;</li>
<li><pre>00:20:e0:3b:13:af,192.168.0.123,laptop,infinite</pre> tells Pi-hole to give the machine with hardware address <code>00:20:e0:3b:13:af</code> the address <code>192.168.0.123</code>, the name <code>laptop</code>, and an infinite DHCP lease<br>&nbsp;</li>
</ul>
</p>
<p>Examples:</p>
<ul>
<li><pre>00:20:e0:3b:13:af,192.168.0.123</pre> tells Pi-hole to give the machine with hardware address <code>00:20:e0:3b:13:af</code> the address <code>192.168.0.123</code><br>&nbsp;</li>
<li><pre>00:20:e0:3b:13:af,laptop</pre>tells Pi-hole to give the machine with hardware address <code>00:20:e0:3b:13:af</code> the name <code>laptop</code><br>&nbsp;</li>
<li><pre>00:20:e0:3b:13:af,192.168.0.123,laptop,infinite</pre> tells Pi-hole to give the machine with hardware address <code>00:20:e0:3b:13:af</code> the address <code>192.168.0.123</code>, the name <code>laptop</code>, and an infinite DHCP lease<br>&nbsp;</li>
</ul>
</div>
<div class="col-xs-12">
<div class="box box-success collapsed-box">
@@ -209,7 +208,7 @@ mg.include('scripts/lua/settings_header.lp','r')
<li> More than one line can be associated (by name, hardware address or UID) with a host. Which one is used (and therefore which address is allocated by DHCP and appears in the DNS) depends on the subnet on which the host last obtained a DHCP lease: the line with an address within the subnet is used. If more than one address is within the subnet, the result is undefined. <strong>A corollary to this is that the name associated with a host defined here does not appear in the DNS until the host obtains a DHCP lease.</strong></li>
<li> The special keyword <code>ignore</code> tells Pi-hole to never offer a DHCP lease to a machine. The machine can be specified by hardware address, client ID or hostname, for instance <code>00:20:e0:3b:13:af,ignore</code>. This is useful when there is another DHCP server on the network which should be used by some machines.</li>
<li> The <code>set:&lt;tag&gt;</code> construct sets the tag whenever this line is in use. This can be used to selectively send DHCP options just for this host. More than one tag can be set per line directive (but not in other places where "set:&lt;tag&gt;" is allowed). When a host matches any directive (or one implied by <code>/etc/ethers</code>) then the special tag "<code>known</code>"" is set. This allows Pi-hole to be configured to ignore requests from unknown machines using a custom config option <code>dhcp-ignore=tag:!known</code> in your own config file. If the host matches only a directive which cannot be used because it specifies an address on different subnet, the tag "<code>known-othernet</code>" is set.</li>
<li> The <code>tag:&lt;tag&gt;</code> construct filters which directives are used; more than one can be provided, in this case the request must match all of them. Tagged directives are used in preference to untagged ones. Note that one of <code>&lt;hwaddr&gt</code>;, <code>&lt;client_id&gt</code>; or <code>&lt;hostname&gt</code>; still needs to be specified (can be a wildcard).</li>
<li> The <code>tag:&lt;tag&gt;</code> construct filters which directives are used; more than one can be provided, in this case the request must match all of them. Tagged directives are used in preference to untagged ones. Note that one of <code>&lt;hwaddr&gt;</code>, <code>&lt;client_id&gt;</code> or <code>&lt;hostname&gt;</code> still needs to be specified (can be a wildcard).</li>
<li> Ethernet addresses (but not client-ids) may have wildcard bytes, so for example <code>00:20:e0:3b:13:*,ignore</code> will cause Pi-hole to ignore a range of hardware addresses.</li>
<li> Hardware addresses normally match any network (ARP) type, but it is possible to restrict them to a single ARP type by preceding them with the ARP-type (in HEX) and "<code>-</code>". so the line <code>06-00:20:e0:3b:13:af,1.2.3.4</code> will only match a Token-Ring hardware address, since the ARP-address type for token ring is <code>6</code>.</li>
<li> As a special case, in DHCPv4, it is possible to include more than one hardware address. eg: <code>11:22:33:44:55:66,12:34:56:78:90:12,192.168.0.2</code>. This allows an IP address to be associated with multiple hardware addresses, and gives Pi-hole permission to abandon a DHCP lease to one of the hardware addresses when another one asks for a lease. Beware that this is a dangerous thing to do, it will only work reliably if only one of the hardware addresses is active at any time and there is no way for dnsmasq to enforce this. It is, for instance, useful to allocate a stable IP address to a laptop which has both wired and wireless interfaces.</li>

View File

@@ -36,7 +36,7 @@ mg.include('scripts/lua/settings_header.lp','r')
<p>ECS (Extended Client Subnet) defines a mechanism for recursive resolvers to send partial client IP address information to authoritative DNS name servers. Content Delivery Networks (CDNs) and latency-sensitive services use this to give geo-located responses when responding to name lookups coming through public DNS resolvers. <em>Note that ECS may result in reduced privacy.</em></p>
</div>
<div class="col-sm-12">
<div class="box collapsed-box">
<div class="box collapsed-box" id="custom-servers-box">
<div class="box-header with-border pointer" data-widget="collapse">
<h3 class="box-title">Custom DNS servers <span id="custom-servers-title"></span></h3>
<div class="box-tools pull-right">
@@ -59,7 +59,7 @@ mg.include('scripts/lua/settings_header.lp','r')
</div>
<div class="box box-warning settings-level-expert d-none">
<div class="box-header with-border">
<h3 class="box-title" data-configkeys="dns.domain dns.expandHosts">DNS domain settings</h3>
<h3 class="box-title" data-configkeys="dns.domain.name dns.expandHosts">DNS domain settings</h3>
</div>
<div class="box-body">
<div class="row">
@@ -68,7 +68,7 @@ mg.include('scripts/lua/settings_header.lp','r')
<div class="form-group">
<div class="input-group">
<div class="input-group-addon">Domain</div>
<input type="text" class="form-control" id="dns.domain" data-key="dns.domain" value="">
<input type="text" class="form-control" id="dns.domain.name" data-key="dns.domain.name" value="">
</div>
</div>
<p>The DNS domains for your Pi-hole. This DNS domain is purely local. FTL may answer queries from its local cache and configuration but *never* forwards any requests upstream *unless* you have configured a dns.revServer exactly for this domain. If no domain is specified and you are using Pi-hole's DHCP server, then any hostnames with a domain part (i.e., with a period) will be disallowed. If a domain is specified, then hostnames with a domain parts matching the domain here are allowed. In addition, when a suffix is set then hostnames without a domain part have the suffix added as an optional domain part.</p>
@@ -98,9 +98,7 @@ mg.include('scripts/lua/settings_header.lp','r')
per-client basis. Other clients can continue to use FTL while
rate-limited clients are short-circuited at the same time.</p>
<p>Rate-limiting may be disabled altogether by setting both
values to zero. See
<a href="https://docs.pi-hole.net/ftldns/configfile/#rate_limit" rel="noopener noreferrer" target="_blank">our documentation</a>
for further details.</p>
values to zero.</p>
</div>
</div>
</div>
@@ -119,7 +117,7 @@ mg.include('scripts/lua/settings_header.lp','r')
<h4>Recommended setting</h4>
<div>
<input type="radio" name="DNSinterface" id="dns.listeningMode-LOCAL" data-key="dns.listeningMode" value="local">
<label for="dns.listeningMode-LOCAL"><strong>Allow only local requests</strong>
<label for="dns.listeningMode-LOCAL"><strong>Allow only local requests</strong></label>
<p class="help-block">Allows only queries from devices that are at most one hop away (local devices)</p>
</div>
</div>
@@ -158,8 +156,8 @@ mg.include('scripts/lua/settings_header.lp','r')
<div class="col-lg-12">
<div>
<input type="checkbox" id="dns.domainNeeded" data-key="dns.domainNeeded" title="domain-needed">
<label for="dns.domainNeeded"><strong>Never forward non-FQDN <code>A</code> and <code>AAAA</code> queries</strong></label>
<p class="help-block">Tells Pi-hole to never forward A or AAAA queries for plain
<label for="dns.domainNeeded"><strong>Never forward non-FQDN queries</strong></label>
<p class="help-block">Tells Pi-hole to never forward queries for plain
names, without dots or domain parts, to upstream nameservers. If
the name is not known from <code>/etc/hosts</code> or DHCP then a "not found"
answer is returned.<br>

View File

@@ -44,7 +44,7 @@ mg.include('scripts/lua/settings_header.lp','r')
<th scope="row">Kernel:</th>
<td><span id="sysinfo-kernel"></span></td>
</tr>
<tr>
<tr title="The machine uptime. For containers, this will match the host uptime">
<th scope="row">Uptime:</th>
<td><span id="sysinfo-uptime"></span></td>
</tr>
@@ -185,7 +185,7 @@ mg.include('scripts/lua/settings_header.lp','r')
</tr>
</tbody>
</table>
<!-- <table class="table table-striped table-bordered nowrap settings-level-expert d-none">
<!-- <table class="table table-striped table-bordered nowrap">
<tbody id="dns-cache-table">
</tbody>
</table> -->
@@ -230,7 +230,7 @@ mg.include('scripts/lua/settings_header.lp','r')
<th scope="row">Unanswered queries:</th>
<td id="sysinfo-dns-replies-unanswered">&nbsp;</td>
</tr>
<tr class="settings-level-expert d-none">
<tr>
<th scope="row">Authoritative replies:</th>
<td id="sysinfo-dns-replies-auth">&nbsp;</td>
</tr>
@@ -260,7 +260,7 @@ mg.include('scripts/lua/settings_header.lp','r')
<div class="col-xs-12 col-md-6 col-lg-3">
<button type="button" class="btn btn-danger confirm-flusharp btn-block button-pad destructive_action" disabled>Flush network table</button>
</div>
<div class="col-xs-12 col-md-6 col-lg-3 settings-level-expert d-none">
<div class="col-xs-12 col-md-6 col-lg-3">
<button type="button" class="btn btn-danger confirm-flushlogs btn-block button-pad destructive_action" disabled>Flush logs (last 24 hours)</button>
</div>
</div>

View File

@@ -75,9 +75,9 @@ td.lookatme {
.lookatme:after {
color: #e33100;
text-shadow: 0 0 5px #e33100;
/* in the html, the lookatme-text attribute must */
/* in the html, the data-lookatme-text attribute must */
/* contain the same text as the .lookatme element */
content: attr(lookatme-text);
content: attr(data-lookatme-text);
padding: inherit;
position: absolute;
inset: 0;
@@ -261,15 +261,28 @@ td.lookatme {
align-self: center;
}
.chart-legend ul {
display: flex;
flex-direction: column;
flex-wrap: wrap;
margin: 0;
padding: 0;
}
.chart-legend li {
cursor: pointer;
cursor: default;
position: relative;
line-height: 1;
margin: 0 0 8px;
align-items: center;
display: flex;
flex-direction: row;
}
.chart-legend li span {
cursor: pointer;
display: inline-block;
margin: 0 10px;
}
.colorBoxWrapper {
@@ -281,14 +294,21 @@ td.lookatme {
}
.chart-legend li .legend-label-text {
color: inherit;
margin: 0;
padding: 0;
line-height: 1;
word-break: break-word;
}
.chart-legend li .legend-label-text:hover {
.chart-legend li a.legend-label-text.clickable:hover {
text-decoration: underline;
}
.chart-legend li a.legend-label-text.clickable {
cursor: pointer; /* Pointer cursor only for clickable items */
}
/* These are needed because AdmintLTE 2.x doesn't support Font Awesome 5.x */
.sidebar-menu > li > a > .fab,
.sidebar-menu > li > a > .far,
@@ -574,12 +594,6 @@ td.details-control {
border: none;
}
.form-control-feedback {
right: 0.5em;
width: 16px;
height: 100%;
}
.loginpage-logo {
margin: 0 0 10px;
}
@@ -1046,6 +1060,8 @@ table.dataTable tbody > tr > .selected {
margin: 5px 0;
min-height: 36px;
padding: 4px 8px;
width: 100%;
height: 100%;
}
#output.pre-taillog {
@@ -1576,3 +1592,24 @@ textarea.field-sizing-content {
line-height: 0.5em;
font-size: 1.7em;
}
.hidden-entry {
opacity: 0;
}
.fade-2s {
animation: fadeOut 2s forwards;
}
.fade-in-transition {
opacity: 0;
transition: opacity 200ms ease-in-out;
}
/* Used in query log page */
td.dnssec {
padding-inline-start: 2.25em !important;
text-indent: -1.25em;
}
td.dnssec i {
text-indent: 0;
margin-left: -0.5rem;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
vendor/chartjs/chart.umd.min.js.map vendored Normal file

File diff suppressed because one or more lines are too long

63
xo.config.js Normal file
View File

@@ -0,0 +1,63 @@
"use strict";
const globals = require("globals");
const { defineConfig } = require("eslint/config");
const compatPlugin = require("eslint-plugin-compat");
module.exports = defineConfig([
{
extends: [compatPlugin.configs["flat/recommended"]],
languageOptions: {
sourceType: "script",
globals: {
...globals.browser,
...globals.jquery,
},
},
prettier: true,
space: 2,
ignores: ["**/vendor/**"],
rules: {
"@stylistic/spaced-comment": "off",
camelcase: [
"error",
{
properties: "never",
},
],
"capitalized-comments": "off",
"new-cap": [
"error",
{
properties: false,
},
],
"no-alert": "off",
"no-console": "error",
// This should be removed later
"no-implicit-globals": "off",
"no-negated-condition": "off",
"promise/prefer-await-to-then": "off",
"prefer-arrow-callback": "error",
"prefer-destructuring": [
// This should be enabled later
"off",
{
object: true,
array: false,
},
],
// This should be reverted to "error" later
strict: ["error", "global"],
"unicorn/no-anonymous-default-export": "off",
"unicorn/no-document-cookie": "off",
"unicorn/no-negated-condition": "off",
"unicorn/prefer-module": "off",
"unicorn/prefer-query-selector": "off",
"unicorn/prefer-string-slice": "off",
"unicorn/prefer-string-raw": "off",
"unicorn/prevent-abbreviations": "off",
"unicorn/switch-case-braces": "off",
},
},
]);