Hier mal ein größerer Anwendungsfall für den pihole2 Adapter.
Bitte um Rückmeldungen, positive wie negative Erfahrungen.
Evtl baue ich das dann als widgets in den Adapter ein.
Bitte auch die Verwendung des Adapters vis-jsontemplate 4.1.3 weiter unten beachten
https://forum.iobroker.net/topic/31521/test-widget-json-template/32?_=1762189668521
Ich habe immer wieder den folgenden Anwendungsfall, der sich mit pihole Bordmitteln nicht so einfach lösen lässt.
Ich habe immer wieder mal Seiten, die aufgrund eines pihole Blocks nicht funktionieren. Darüber hinaus funken hier mehrere Geräte wirklich sehr oft nach Hause, deren requests durch pihole geblockt werden. Dies verhindert leider effizient die Suche nach den betroffenen domains des blocks, der wieder aufgehoben werden soll, da das logfile relativ voll ist.
Auch interessiert mich immer wieder mal, wer hier so nach Hause telefoniert.
Diejenigen domains, die ich aber schon mal geprüft habe will ich nicht erneut prüfen.
Daher nun der folgende Versuch einer Lösung mittels 2er Widgets die ich mit dem jsontemplate Adapter erstellt habe.
Widget 1
listet alle domains absteigend nach vorkommen über alle clients dar.
jede einzelne domain kann dann auf die Merkliste gesetzt werden.
die Liste kann nach einem Suchbegriff oder Regex gefiltert werden.
Mehrere Sichtbare domains können über ein Sammel-Checkbox ebenfalls in einem Schritt gemerkt werden.
Das widfget besteht 2 Reitern, einmal alle gemerkten domains, sowie alle die Nicht gemerkt worden sind.
2b733665-5606-4cb2-915a-a319c511ef3c-image.png
Die Konfiguration erfolgt im ersten Abschnitt des Templates
// config area
// adapter instance
const pihole_adapter_instance = 0; //Nummer der Instanz
const domain_count=200; //maximale Anzahl der abzufragenden domains
const domain_blocked=true; //Abfrage der geblockten Domains im standard, Alternativ geht das auch mit den nicht geblockten domänen
Das Template muss im entsprechenden Feld eingetragen werden. Zusätzlich muss noch ein Datenpunkt vom typ String angelegt werden und wie hier verknüpft werden.
Darin werden die bekannten domänen gespeichert.
9cf63a60-9a51-458d-b42a-c7a063d0b1aa-image.png
Template
Spoiler
<%
// config area
// adapter instance
const pihole_adapter_instance = 0;
const domain_count=200;
const domain_blocked=true;
%>
<%
//javascript code der per ejs interpretiert wird
const knownDP = Object.keys(dp)[0] || "";
const knownValue = dp[knownDP] || "[]";
const knownValueSet = new Set(JSON.parse(knownValue));
const adapterinstance = "pi-hole2."+pihole_adapter_instance;
//debugger;
const cookieKey = widgetID+"selectedTab";
const selectedTab = localStorage.getItem(cookieKey)||"tab-all";
// Abruf der Daten vom pihole2 adapter
const apiresult = await getTopDomains(domain_count,domain_blocked);
// Aufbau Index
const domainCountMap = new Map((apiresult.domains || []).map(d => [d.domain, d.count]));
// Aufbau der Tabellendaten knownDomains
const savedList = [...knownValueSet].map(domain => ({
domain,
count: domainCountMap.get(domain) || 0
}));
// Hilfsfunktion für async SendTo
async function sendToAsync(instance, command, sendData) {
return new Promise((resolve, reject) => {
try {
vis.conn.sendTo(instance, command, sendData, function (receiveData) {
resolve(receiveData);
});
} catch (error) {
reject(error);
}
});
}
//Hilfsfunktion Abruf der TopDomains blocked/not blocked
async function getTopDomains(count,blocked) {
const blockedText = blocked ? "true":"false";
return await sendToAsync(adapterinstance,"piholeapi", {
method: 'GET',
endpoint: "/stats/top_domains?count="+count+"&blocked="+blockedText,
});
};
%>
<style>
.pihole.select .tabs {
display: flex;
gap: .1rem;
}
.pihole.select .tabs button {
padding: .4rem .8rem;
border: 1px solid #ccc;
background: #f7f7f7;
cursor: pointer;
}
.pihole.select .tabs button.active {
background: #e9eefc;
border-color: #8aa3ff;
}
.pihole.select .tabpanel {
display: none;
}
.pihole.select .tabpanel.active {
display: block;
}
.pihole.select table {
width: 100%;
border-collapse: collapse;
}
.pihole.select th,
.pihole.select td {
padding: .3rem .4rem;
xborder-bottom: 1px solid #eee;
}
.pihole.select .check {
text-align: center;
width: 10%;
}
.pihole.select .domain {
text-align: left;
xwidth: 70%;
display: flex;
align-items: center;
gap: .4rem; /* optional */
}
.pihole.select .domain .filter-all {
flex: 1; /* <-- restliche Breite */
min-width: 0; /* wichtig für Firefox/Edge */
box-sizing: border-box;
}
.pihole.select .count {
text-align: end;
width: 20%;
}
.muted {
color: #888;
font-size: .9em;
}
.pihole.select th .filter {
display:block; margin-top:.25rem;
}
.pihole.select th .filter input {
width:100%;
box-sizing:border-box;
padding:.25rem .35rem;
border:1px solid #ccc;
border-radius:4px;
font-size:.9rem;
}
.pihole.select th .filter small {
color:#666;
}
</style>
<div class="pihole select">
<div class="tabs" role="tablist">
<button type="button" role="tab" aria-controls="tab-all" class="tabbtn <%= selectedTab=="tab-all"?"active":""%>">Alle Domains</button>
<button type="button" role="tab" aria-controls="tab-saved" class="tabbtn <%= selectedTab=="tab-saved"?"active":""%>">Gemerkte Domains</button>
</div>
<div id="tab-all" class="tabpanel <%= selectedTab=="tab-all"?"active":""%>" role="tabpanel" aria-label="Alle Domains">
<table data-table="all">
<thead>
<tr>
<th class="check">
<span><input class="bulk" type="checkbox" data-action="check" checked></span>
</th>
<th class="domain">
<span>domain</span>
<input type="text" autofocus value="" class="filter-all">
</th>
<th class="count"><span>#</span></th>
</tr>
</thead>
<tbody>
<% (apiresult.domains || []).forEach(domain => {
if (!knownValueSet.has(domain.domain)) { %>
<tr data-domain="<%= domain.domain %>">
<td class="check">
<input type="checkbox" data-id="<%= domain.domain %>">
</td>
<td class="domain"><%= domain.domain %></td>
<td class="count"><%= domain.count %></td>
</tr>
<% } }) %>
</tbody>
</table>
</div>
<div id="tab-saved" class="tabpanel <%= selectedTab=="tab-saved"?"active":""%>" role="tabpanel" aria-label="Gemerkte Domains">
<table data-table="saved">
<thead>
<tr>
<th class="check">
<input class="bulk" type="checkbox" data-action="uncheck">
</th>
<th class="domain">
<span>domain</span>
<input type="text" autofocus value="" class="filter-all">
</th>
<th class="count">#</th>
</tr>
</thead>
<tbody>
<% savedList.forEach(item => { %>
<tr data-domain="<%= item.domain %>">
<td class="check">
<input type="checkbox" data-id="<%= item.domain %>" checked>
</td>
<td class="domain">
<%= item.domain %>
<% if (!domainCountMap.has(item.domain)) { %>
<span class="muted">(derzeit nicht in Liste)</span>
<% } %>
</td>
<td class="count"><%= item.count %></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
<script>
//Javascript code der im browser läuft
(async () => {
// Funktionen für Tabs umschalten
const cookieKey = "<%= widgetID %>selectedTab";
const selectedTab = localStorage.getItem(cookieKey)||"tab-all";
const $tabs = $(".tabbtn");
const $panels = $(".tabpanel");
$tabs.on("click", function() {
//debugger;
$tabs.removeClass("active");
$(this).addClass("active");
const id = $(this).attr("aria-controls");
$panels.removeClass("active");
$("#"+id).addClass("active");
localStorage.setItem(cookieKey,id);
});
// Daten aus den Datenpunkt wird ins Skriptn übernommen
//debugger;
const knownDP = "<%- knownDP %>";
const initial = JSON.parse(atob("<%= btoa(knownValue) %>"));
const knownSet = new Set(initial);
// Helpers
function writeDP() {
//debugger;
vis.setValue(knownDP, JSON.stringify(Array.from(knownSet)));
}
function makeRow(domain, count, checked=true) {
return $(`
<tr data-domain="${domain}">
<td class="check"><input type="checkbox" data-id="${domain}" ${checked ? "checked":""}></td>
<td class="domain">${domain}</td>
<td class="count">${Number.isFinite(count) ? count : 0}</td>
</tr>
`);
}
// Eventhandler Merken einer Domain bzw auch wieder entfernen
$('.pihole').on("change", 'tbody .check input', function(evt) {
const id = $(evt.target).data("id");
const isChecked = $(evt.target).is(":checked");
const $row = $(evt.target).closest('tr');
//hinzufügen oder entfernen der gewählten informationen
if (isChecked) {
// nach "saved" verschieben
knownSet.add(id);
// Count aus aktueller Zeile holen
const cnt = parseInt($row.find('.count').text(), 10) || 0;
// In saved hinzufügen, falls noch nicht vorhanden
if ($('[data-table="saved"] tr[data-domain="'+id+'"]').length === 0) {
$('[data-table="saved"] tbody').append(makeRow(id, cnt, true));
} else {
// sicherstellen, dass dort gecheckt ist
$('[data-table="saved"] tr[data-domain="'+id+'"] input[type="checkbox"]').prop('checked', true);
}
// Falls Änderung aus "all" kam: Zeile dort entfernen (weil nun gemerkt)
if ($row.closest('table').attr('data-table') === 'all') {
$row.remove();
}
} else {
// -> zurück nach "all" verschieben
knownSet.delete(id);
// Count aus aktueller Zeile holen
const cnt = parseInt($row.find('.count').text(), 10) || 0;
// In all hinzufügen, falls noch nicht vorhanden
if ($('[data-table="all"] tr[data-domain="'+id+'"]').length === 0) {
$('[data-table="all"] tbody').append(makeRow(id, cnt, false));
} else {
// sicherstellen, dass dort ungecheckt ist
$('[data-table="all"] tr[data-domain="'+id+'"] input[type="checkbox"]').prop('checked', false);
}
// Falls Änderung aus "saved" kam: Zeile dort entfernen
if ($row.closest('table').attr('data-table') === 'saved') {
$row.remove();
}
}
writeDP();
});
// Ereignishandler zum hinzufügen/entfernen aller sichtbaren Elemente
$('.pihole').on('click', 'thead .bulk', function() {
//debugger;
const action = $(this).data('action'); // "check" | "uncheck"
const $table = $(this).closest('table');
const ids = $table.find('tbody tr:visible .check input').map((_,el)=>$(el).data('id')).get();
//debugger;
if (!ids.length) return;
if (action === 'check') {
ids.forEach(id => knownSet.add(id));
} else {
ids.forEach(id => knownSet.delete(id));
}
writeDP();
});
// Domain Filter
const filterKey = "<%= widgetID %>filterAll";
const $filter = $('.filter-all');
const $rowsAll = $('[data-table="all"] tbody tr');
const $visCount = $('#all-visible-count');
//Funktion zum filtern
function applyAllFilter(qRaw,table) {
//debugger;
const q = (qRaw || '').trim();
let shown = 0;
// Modus erkennen: /regex/ oder Wildcards
let isRegex = false, re = null;
if (q.startsWith('/') && q.endsWith('/') && q.length > 2) {
try {
re = new RegExp(q.slice(1, -1), 'i');
isRegex = true;
} catch(e) {
/* fallback unten */
}
}
if (!isRegex) {
// * als Wildcard; rest escapen
const esc = q.replace(/[.*+?^${}()|[]\]/g, '\\$&').replace(/\\\*/g, '.*');
re = new RegExp(esc, 'i');
}
$('[data-table="'+table+'"] tbody tr').each((_, tr) => {
const dom = tr.getAttribute('data-domain') || tr.querySelector('.domain')?.textContent || '';
const match = q === '' ? true : re.test(dom);
tr.style.display = match ? '' : 'none';
if (match) shown++;
});
$visCount.text(shown);
}
// Entprellen der Eingabe
let fto;
$filter.on('input', function() {
const v = this.value;
clearTimeout(fto);
const table = $(this.closest("table")).data("table");
fto = setTimeout(() => {
localStorage.setItem(filterKey, v);
applyAllFilter(v,table);
}, 120);
});
// Beim Umschalten auf den Tab „Alle“ Filter erneut anwenden (falls DOM neu geändert wurde)
$('.tabbtn[aria-controls="tab-all"]').on('click', () => applyAllFilter($filter.val()));
// Persistierten Filter laden & anwenden
const initialFilter = localStorage.getItem(filterKey) || "";
$filter.val(initialFilter);
applyAllFilter(initialFilter);
})();
</script>
Widget2
Dieses Widget zeigt alle geblockten requests an. Auch hier können ein paar Konfigurationen vorgenommen werden.
Aufgrund dieser Lösung wurde noch ein kleiner glitch im jsontemplate entdeckt. Daher muss hier die Version 4.1.3 verwendet werden, ansonsten werden im 2.widget die Liste nicht korrekt dargestellt.
d6c55515-a712-475f-80b8-2f417be28632-image.png
Das Template muss im entsprechenden Feld eingetragen werden. Zusätzlich muss noch ein Datenpunkt vom typ String angelegt werden und wie hier verknüpft werden.
Darin werden die bekannten domänen gespeichert.
<%
// config area
// adapter instance
const pihole_adapter_instance = 0; //Adapter Instanz
const blocked = true; //Wie oben, ob geblockte oder nicht geblockte requests abgerufen werden soll
const request_count=500; // Anzahl der letzten requests, also ab dem jetzigen Zeitpunkt die letzten 500 requests.
%>
943fa6bf-e513-494e-a836-249b47749fb1-image.png
Template
Spoiler
<%
// config area
// adapter instance
const pihole_adapter_instance = 0;
const blocked = true;
const request_count=500;
%>
<%
const knownDP = Object.keys(dp)[0] || "";
const knownValue = dp[knownDP] || "[]";
%>
<style>
.pihole.requests {
font-size: 0.8em;
}
.pihole.requests .col {
text-align: left;
}
.pihole.requests .col.time {
width: 15%;
}
.pihole.requests .col.status {
width: 5%;
}
.pihole.requests .col.type {
width: 5%;
}
.pihole.requests .col.domain {
width: 20%;
}
.pihole.requests .col.client_name {
width: 20%;
}
.pihole.requests .col.client_ip {
width: 10%;
}
</style>
<div class="pihole requests">
<div>
<select class="clientsb" name="clients">
<option value="">no Clients</option>
</select>
</div>
<table data-table="all">
<thead>
<tr>
<th class="col time">Time</th>
<th class="col status">Status</th>
<th class="col type">Type</th>
<th class="col domain">Domain</th>
<th class="col client_name">Client Name</th>
<th class="col client_ip">Client IP</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
<script>
//Javascript code der im browser läuft
(async () => {
const pihole_adapter_instance = 0;
const adapterinstance = "pi-hole2."+pihole_adapter_instance;
const blocked = <%- blocked %>;
const request_count=<%- request_count %>;
const knownDP = "<%- knownDP %>";
const knownValue = atob("<%= btoa(knownValue) %>");
const knownValueSet = new Set(JSON.parse(knownValue));
let fto;
let $table=$(".pihole.requests tbody");
await fillClients();
function fillTable() {
let table=$(".pihole.requests tbody");
}
async function fillClients() {
const key = "<%= widgetID %>selectedclient";
const selectedClient = localStorage.getItem(key)||"";
let clients = await getClients();
let $clientselect = $(".pihole.requests .clientsb");
const clientlist = Object.entries(clients.clients)
.map(([ip, v]) => ({
ip, ...v
}))
.sort((a, b) => b.total - a.total);
$clientselect.empty();
$clientselect.append($('<option value="" '+isSelected(selectedClient,"")+'>nothing selected</option>'));
clientlist.map(el=>$clientselect.append($('<option value="'+el.ip+'" '+isSelected(selectedClient,el.ip)+'>'+el.name+'/'+el.ip+'</option>')));
let that=this;
$clientselect.on('change', function() {
const v = this.value;
clearTimeout(fto);
fto = setTimeout(() => {
localStorage.setItem(key, v);
applySelect(v);
}, 120);
});
if (selectedClient) {
applySelect(selectedClient);
}
async function applySelect(client) {
let requests = await getFilteredRequests(client /*"192.168.1.232"*/,request_count);
$table.empty();
requests.queries
.filter(req=>!knownValueSet.has(req.domain))
.map(req=>$table.append(makeRow(req)));
}
}
function findIPv4(adresses) {
let ips = adresses||[];
return (adresses||"").split(",").filter(ip=>ip.split(".").length-1)[0]||"";
}
function isSelected(v1,v2) {
return (v1==v2) ? "selected":"";
}
function makeRow(item) {
return $(`
<tr>
<td class="col time">`+ formatDate(item.time)+`</th>
<td class="col status">`+ item.status+`</th>
<td class="col type">`+ item.type+`</th>
<td class="col domain">`+ item.domain+`</th>
<td class="col client_name">`+ item.client.name+`</th>
<td class="col client_ip">`+ item.client.ip+`</th>
</tr>
`);
}
async function getData(client) {
if (!client) {
client = clients[0];
}
return await getFilteredRequests(client /*"192.168.1.232"*/,request_count);
}
async function sendToAsync(instance, command, sendData) {
return new Promise((resolve, reject) => {
try {
vis.conn.sendTo(instance, command, sendData, function (receiveData) {
resolve(receiveData);
});
} catch (error) {
reject(error);
}
});
}
async function getClients() {
return await sendToAsync(adapterinstance,"piholeapi", {
method: 'GET',
endpoint: "/history/clients?N=200",
});
};
async function getFilteredRequests(client_ip,count) {
return await sendToAsync(adapterinstance,"piholeapi", {
method: 'GET',
endpoint: "/queries?client_ip="+client_ip+"&upstream=blocklist&order%5B0%5D%5Bdir%5D=desc&start=0&length="+count,
});
};
function formatDate(ts) {
const date = new Date(ts * 1000);
return date.toISOString();
}
})();
</script>