Add pihole integration (#860)

*  Add pihole integration

* Update src/widgets/adhole/AdHoleControls.tsx

Co-authored-by: Larvey <39219859+LarveyOfficial@users.noreply.github.com>

* Update src/tools/client/math.ts

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>

* Update src/widgets/dnshole/DnsHoleSummary.tsx

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>

---------

Co-authored-by: Larvey <39219859+LarveyOfficial@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Manuel
2023-05-06 19:51:53 +02:00
committed by GitHub
parent 6ad799efe8
commit 92e8d79c5a
22 changed files with 1289 additions and 10 deletions

View File

@@ -0,0 +1,16 @@
{
"descriptor": {
"name": "DNS hole controls",
"description": "Control PiHole or AdGuard from your dashboard"
},
"card": {
"buttons": {
"enableAll": "Enable all",
"disableAll": "Disable all"
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled"
}
}
}

View File

@@ -0,0 +1,21 @@
{
"descriptor": {
"name": "DNS hole summary",
"description": "Displays important data from PiHole or AdGuard",
"settings": {
"title": "Settings for DNS Hole summary",
"usePiHoleColors": {
"label": "Use colors from PiHole"
}
}
},
"card": {
"metrics": {
"domainsOnAdlist": "Domains on adlists",
"queriesToday": "Queries today",
"adsBlockedTodayPercentage": "{{percentage}}%",
"queriesBlockedTodayPercentage": "blocked today",
"queriesBlockedToday": "blocked today"
}
}
}

View File

@@ -85,6 +85,11 @@ export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png', image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png',
label: 'Plex', label: 'Plex',
}, },
{
value: 'pihole',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pihole.png',
label: 'PiHole',
},
].filter((x) => Object.keys(integrationFieldProperties).includes(x.value)); ].filter((x) => Object.keys(integrationFieldProperties).includes(x.value));
const getNewProperties = (value: string | null): AppIntegrationPropertyType[] => { const getNewProperties = (value: string | null): AppIntegrationPropertyType[] => {

View File

@@ -131,11 +131,6 @@ export const AvailableElementTypes = ({
icon={<IconBoxAlignTop size={40} strokeWidth={1.3} />} icon={<IconBoxAlignTop size={40} strokeWidth={1.3} />}
onClick={onClickCreateCategory} onClick={onClickCreateCategory}
/> />
{/*<ElementItem
name="Static Element"
icon={<IconTextResize size={40} strokeWidth={1.3} />}
onClick={onOpenStaticElements}
/>*/}
</Group> </Group>
</> </>
); );

View File

@@ -29,7 +29,7 @@ export const GenericAvailableElementType = ({
: image; : image;
return ( return (
<Grid.Col span="auto"> <Grid.Col xs={12} sm={4} md={3}>
<Card style={{ height: '100%' }}> <Card style={{ height: '100%' }}>
<Stack justify="space-between" style={{ height: '100%' }}> <Stack justify="space-between" style={{ height: '100%' }}>
<Stack spacing="xs"> <Stack spacing="xs">

View File

@@ -2,8 +2,8 @@ import { GridStack } from 'fily-publish-gridstack';
import { MutableRefObject, RefObject } from 'react'; import { MutableRefObject, RefObject } from 'react';
import { AppType } from '../../../types/app'; import { AppType } from '../../../types/app';
import Widgets from '../../../widgets'; import Widgets from '../../../widgets';
import { IWidget, IWidgetDefinition } from '../../../widgets/widgets';
import { WidgetWrapper } from '../../../widgets/WidgetWrapper'; import { WidgetWrapper } from '../../../widgets/WidgetWrapper';
import { IWidget, IWidgetDefinition } from '../../../widgets/widgets';
import { appTileDefinition } from '../Tiles/Apps/AppTile'; import { appTileDefinition } from '../Tiles/Apps/AppTile';
import { GridstackTileWrapper } from '../Tiles/TileWrapper'; import { GridstackTileWrapper } from '../Tiles/TileWrapper';
import { useGridstackStore } from './gridstack/store'; import { useGridstackStore } from './gridstack/store';

View File

@@ -6,9 +6,8 @@ import Consola from 'consola';
import { NextApiRequest, NextApiResponse } from 'next'; import { NextApiRequest, NextApiResponse } from 'next';
import { BackendConfigType, ConfigType } from '../../../types/config';
import { getConfig } from '../../../tools/config/getConfig'; import { getConfig } from '../../../tools/config/getConfig';
import widgets from '../../../widgets'; import { BackendConfigType, ConfigType } from '../../../types/config';
import { IRssWidget } from '../../../widgets/rss/RssWidgetTile'; import { IRssWidget } from '../../../widgets/rss/RssWidgetTile';
function Put(req: NextApiRequest, res: NextApiResponse) { function Put(req: NextApiRequest, res: NextApiResponse) {

View File

@@ -0,0 +1,53 @@
/* eslint-disable no-await-in-loop */
import { z } from 'zod';
import { getCookie } from 'cookies-next';
import { NextApiRequest, NextApiResponse } from 'next';
import { getConfig } from '../../../../tools/config/getConfig';
import { findAppProperty } from '../../../../tools/client/app-properties';
import { PiHoleClient } from '../../../../tools/server/sdk/pihole/piHole';
const getQuerySchema = z.object({
status: z.enum(['enabled', 'disabled']),
});
export const Post = async (request: NextApiRequest, response: NextApiResponse) => {
const configName = getCookie('config-name', { req: request });
const config = getConfig(configName?.toString() ?? 'default');
const parseResult = getQuerySchema.safeParse(request.query);
if (!parseResult.success) {
response.status(400).json({ message: 'invalid query parameters, please specify the status' });
return;
}
const applicableApps = config.apps.filter((x) => x.integration?.type === 'pihole');
for (let i = 0; i < applicableApps.length; i += 1) {
const app = applicableApps[i];
const pihole = new PiHoleClient(
app.url,
findAppProperty(app, 'password')
);
switch (parseResult.data.status) {
case 'enabled':
await pihole.enable();
break;
case 'disabled':
await pihole.disable();
break;
}
}
response.status(200).json({});
};
export default async (request: NextApiRequest, response: NextApiResponse) => {
if (request.method === 'POST') {
return Post(request, response);
}
return response.status(405).json({});
};

View File

@@ -0,0 +1,273 @@
import Consola from 'consola';
import { describe, it, vi, expect } from 'vitest';
import { createMocks } from 'node-mocks-http';
import GetSummary from './summary';
import { ConfigType } from '../../../../types/config';
const mockedGetConfig = vi.fn();
describe('DNS hole', () => {
it('combine and return aggregated data', async () => {
// arrange
const { req, res } = createMocks({
method: 'GET',
});
vi.mock('./../../../../tools/config/getConfig.ts', () => ({
get getConfig() {
return mockedGetConfig;
},
}));
mockedGetConfig.mockReturnValue({
apps: [
{
url: 'http://pi.hole',
integration: {
type: 'pihole',
properties: [
{
field: 'password',
type: 'private',
value: 'hf3829fj238g8',
},
],
},
},
],
} as ConfigType);
const errorLogSpy = vi.spyOn(Consola, 'error');
const warningLogSpy = vi.spyOn(Consola, 'warn');
fetchMock.mockResponse((request) => {
if (request.url === 'http://pi.hole/admin/api.php?summaryRaw&auth=hf3829fj238g8') {
return JSON.stringify({
domains_being_blocked: 780348,
dns_queries_today: 36910,
ads_blocked_today: 9700,
ads_percentage_today: 26.280142,
unique_domains: 6217,
queries_forwarded: 12943,
queries_cached: 13573,
clients_ever_seen: 20,
unique_clients: 17,
dns_queries_all_types: 36910,
reply_UNKNOWN: 947,
reply_NODATA: 3313,
reply_NXDOMAIN: 1244,
reply_CNAME: 5265,
reply_IP: 25635,
reply_DOMAIN: 97,
reply_RRNAME: 4,
reply_SERVFAIL: 28,
reply_REFUSED: 0,
reply_NOTIMP: 0,
reply_OTHER: 0,
reply_DNSSEC: 0,
reply_NONE: 0,
reply_BLOB: 377,
dns_queries_all_replies: 36910,
privacy_level: 0,
status: 'enabled',
gravity_last_updated: {
file_exists: true,
absolute: 1682216493,
relative: {
days: 5,
hours: 17,
minutes: 52,
},
},
});
}
return Promise.reject(new Error(`Bad url: ${request.url}`));
});
// Act
await GetSummary(req, res);
// Assert
expect(res._getStatusCode()).toBe(200);
expect(res.finished).toBe(true);
expect(JSON.parse(res._getData())).toEqual({
adsBlockedToday: 9700,
adsBlockedTodayPercentage: 0.26280140883229475,
dnsQueriesToday: 36910,
domainsBeingBlocked: 780348,
status: [
{
status: 'enabled',
},
],
});
expect(errorLogSpy).not.toHaveBeenCalled();
expect(warningLogSpy).not.toHaveBeenCalled();
errorLogSpy.mockRestore();
});
it('combine and return aggregated data when multiple instances', async () => {
// arrange
const { req, res } = createMocks({
method: 'GET',
});
vi.mock('./../../../../tools/config/getConfig.ts', () => ({
get getConfig() {
return mockedGetConfig;
},
}));
mockedGetConfig.mockReturnValue({
apps: [
{
id: 'app1',
url: 'http://pi.hole',
integration: {
type: 'pihole',
properties: [
{
field: 'password',
type: 'private',
value: 'hf3829fj238g8',
},
],
},
},
{
id: 'app2',
url: 'http://pi2.hole',
integration: {
type: 'pihole',
properties: [
{
field: 'password',
type: 'private',
value: 'ayaka',
},
],
},
},
],
} as ConfigType);
const errorLogSpy = vi.spyOn(Consola, 'error');
const warningLogSpy = vi.spyOn(Consola, 'warn');
fetchMock.mockResponse((request) => {
if (request.url === 'http://pi.hole/admin/api.php?summaryRaw&auth=hf3829fj238g8') {
return JSON.stringify({
domains_being_blocked: 3,
dns_queries_today: 8,
ads_blocked_today: 5,
ads_percentage_today: 26,
unique_domains: 4,
queries_forwarded: 2,
queries_cached: 2,
clients_ever_seen: 2,
unique_clients: 3,
dns_queries_all_types: 3,
reply_UNKNOWN: 2,
reply_NODATA: 3,
reply_NXDOMAIN: 5,
reply_CNAME: 6,
reply_IP: 5,
reply_DOMAIN: 3,
reply_RRNAME: 2,
reply_SERVFAIL: 2,
reply_REFUSED: 0,
reply_NOTIMP: 0,
reply_OTHER: 0,
reply_DNSSEC: 0,
reply_NONE: 0,
reply_BLOB: 1,
dns_queries_all_replies: 36910,
privacy_level: 0,
status: 'enabled',
gravity_last_updated: {
file_exists: true,
absolute: 1682216493,
relative: {
days: 5,
hours: 17,
minutes: 52,
},
},
});
}
if (request.url === 'http://pi2.hole/admin/api.php?summaryRaw&auth=ayaka') {
return JSON.stringify({
domains_being_blocked: 1,
dns_queries_today: 3,
ads_blocked_today: 2,
ads_percentage_today: 47,
unique_domains: 4,
queries_forwarded: 4,
queries_cached: 2,
clients_ever_seen: 2,
unique_clients: 2,
dns_queries_all_types: 1,
reply_UNKNOWN: 3,
reply_NODATA: 2,
reply_NXDOMAIN: 1,
reply_CNAME: 3,
reply_IP: 2,
reply_DOMAIN: 97,
reply_RRNAME: 4,
reply_SERVFAIL: 28,
reply_REFUSED: 0,
reply_NOTIMP: 0,
reply_OTHER: 0,
reply_DNSSEC: 0,
reply_NONE: 0,
reply_BLOB: 2,
dns_queries_all_replies: 4,
privacy_level: 0,
status: 'disabled',
gravity_last_updated: {
file_exists: true,
absolute: 1682216493,
relative: {
days: 5,
hours: 17,
minutes: 52,
},
},
});
}
return Promise.reject(new Error(`Bad url: ${request.url}`));
});
// Act
await GetSummary(req, res);
// Assert
expect(res._getStatusCode()).toBe(200);
expect(res.finished).toBe(true);
expect(JSON.parse(res._getData())).toStrictEqual({
adsBlockedToday: 7,
adsBlockedTodayPercentage: 0.6363636363636364,
dnsQueriesToday: 11,
domainsBeingBlocked: 4,
status: [
{
appId: 'app1',
status: 'enabled',
},
{
appId: 'app2',
status: 'disabled',
},
],
});
expect(errorLogSpy).not.toHaveBeenCalled();
expect(warningLogSpy).not.toHaveBeenCalled();
errorLogSpy.mockRestore();
});
});

View File

@@ -0,0 +1,60 @@
import Consola from 'consola';
import { getCookie } from 'cookies-next';
import { NextApiRequest, NextApiResponse } from 'next';
import { findAppProperty } from '../../../../tools/client/app-properties';
import { getConfig } from '../../../../tools/config/getConfig';
import { PiHoleClient } from '../../../../tools/server/sdk/pihole/piHole';
import { AdStatistics } from '../../../../widgets/dnshole/type';
export const Get = async (request: NextApiRequest, response: NextApiResponse) => {
const configName = getCookie('config-name', { req: request });
const config = getConfig(configName?.toString() ?? 'default');
const applicableApps = config.apps.filter((x) => x.integration?.type === 'pihole');
const data: AdStatistics = {
domainsBeingBlocked: 0,
adsBlockedToday: 0,
adsBlockedTodayPercentage: 0,
dnsQueriesToday: 0,
status: [],
};
const adsBlockedTodayPercentageArr: number[] = [];
for (let i = 0; i < applicableApps.length; i += 1) {
const app = applicableApps[i];
try {
const piHole = new PiHoleClient(app.url, findAppProperty(app, 'password'));
// eslint-disable-next-line no-await-in-loop
const summary = await piHole.getSummary();
data.domainsBeingBlocked += summary.domains_being_blocked;
data.adsBlockedToday += summary.ads_blocked_today;
data.dnsQueriesToday += summary.dns_queries_today;
data.status.push({
status: summary.status,
appId: app.id,
});
adsBlockedTodayPercentageArr.push(summary.ads_percentage_today);
} catch (err) {
Consola.error(`Failed to communicate with PiHole at ${app.url}: ${err}`);
}
}
data.adsBlockedTodayPercentage = data.adsBlockedToday / data.dnsQueriesToday;
if (Number.isNaN(data.adsBlockedTodayPercentage)) {
data.adsBlockedTodayPercentage = 0;
}
return response.status(200).json(data);
};
export default async (request: NextApiRequest, response: NextApiResponse) => {
if (request.method === 'GET') {
return Get(request, response);
}
return response.status(405);
};

View File

@@ -0,0 +1,4 @@
import { ConfigAppType, IntegrationField } from '../../types/app';
export const findAppProperty = (app: ConfigAppType, key: IntegrationField) =>
app.integration?.properties.find((prop) => prop.field === key)?.value ?? '';

18
src/tools/client/math.ts Normal file
View File

@@ -0,0 +1,18 @@
const ranges = [
{ divider: 1e18, suffix: 'E' },
{ divider: 1e15, suffix: 'P' },
{ divider: 1e12, suffix: 'T' },
{ divider: 1e9, suffix: 'G' },
{ divider: 1e6, suffix: 'M' },
{ divider: 1e3, suffix: 'k' },
];
export const formatNumber = (n: number, decimalPlaces: number) => {
// eslint-disable-next-line no-restricted-syntax
for (const range of ranges) {
if (n < range.divider) continue;
return (n / range.divider).toFixed(decimalPlaces) + range.suffix;
}
return n.toFixed(decimalPlaces);
};

View File

@@ -0,0 +1,346 @@
import Consola from 'consola';
import { vi, describe, it, expect } from 'vitest';
import { PiHoleClient } from './piHole';
describe('PiHole API client', () => {
it('summary - throw exception when response status code is not 200', async () => {
// arrange
const errorLogSpy = vi.spyOn(Consola, 'error');
const warningLogSpy = vi.spyOn(Consola, 'warn');
fetchMock.mockResponse((request) => {
if (request.url === 'http://pi.hole/admin/api.php?summaryRaw&auth=nice') {
return {
status: 404,
};
}
return Promise.reject(new Error(`Bad url: ${request.url}`));
});
const client = new PiHoleClient('http://pi.hole', 'nice');
// Act && Assert
await expect(() => client.getSummary()).rejects.toThrowErrorMatchingInlineSnapshot(
'"Status code does not indicate success: 404"'
);
expect(errorLogSpy).not.toHaveBeenCalled();
expect(warningLogSpy).not.toHaveBeenCalledOnce();
errorLogSpy.mockRestore();
});
it('summary -throw exception when response is empty', async () => {
// arrange
const errorLogSpy = vi.spyOn(Consola, 'error');
const warningLogSpy = vi.spyOn(Consola, 'warn');
fetchMock.mockResponse((request) => {
if (request.url === 'http://pi.hole/admin/api.php?summaryRaw&auth=nice') {
return JSON.stringify([]);
}
return Promise.reject(new Error(`Bad url: ${request.url}`));
});
const client = new PiHoleClient('http://pi.hole', 'nice');
// Act && Assert
await expect(() => client.getSummary()).rejects.toThrowErrorMatchingInlineSnapshot(
'"Response does not indicate success. Authentication is most likely invalid: "'
);
expect(errorLogSpy).not.toHaveBeenCalled();
expect(warningLogSpy).not.toHaveBeenCalledOnce();
errorLogSpy.mockRestore();
});
it('summary -fetch and return object when success', async () => {
// arrange
const errorLogSpy = vi.spyOn(Consola, 'error');
const warningLogSpy = vi.spyOn(Consola, 'warn');
fetchMock.mockResponse((request) => {
if (request.url === 'http://pi.hole/admin/api.php?summaryRaw&auth=nice') {
return JSON.stringify({
domains_being_blocked: 780348,
dns_queries_today: 36910,
ads_blocked_today: 9700,
ads_percentage_today: 26.280142,
unique_domains: 6217,
queries_forwarded: 12943,
queries_cached: 13573,
clients_ever_seen: 20,
unique_clients: 17,
dns_queries_all_types: 36910,
reply_UNKNOWN: 947,
reply_NODATA: 3313,
reply_NXDOMAIN: 1244,
reply_CNAME: 5265,
reply_IP: 25635,
reply_DOMAIN: 97,
reply_RRNAME: 4,
reply_SERVFAIL: 28,
reply_REFUSED: 0,
reply_NOTIMP: 0,
reply_OTHER: 0,
reply_DNSSEC: 0,
reply_NONE: 0,
reply_BLOB: 377,
dns_queries_all_replies: 36910,
privacy_level: 0,
status: 'enabled',
gravity_last_updated: {
file_exists: true,
absolute: 1682216493,
relative: {
days: 5,
hours: 17,
minutes: 52,
},
},
});
}
return Promise.reject(new Error(`Bad url: ${request.url}`));
});
const client = new PiHoleClient('http://pi.hole', 'nice');
// Act
const summary = await client.getSummary();
// Assert
expect(summary).toStrictEqual({
domains_being_blocked: 780348,
dns_queries_today: 36910,
ads_blocked_today: 9700,
ads_percentage_today: 26.280142,
unique_domains: 6217,
queries_forwarded: 12943,
queries_cached: 13573,
clients_ever_seen: 20,
unique_clients: 17,
dns_queries_all_types: 36910,
reply_UNKNOWN: 947,
reply_NODATA: 3313,
reply_NXDOMAIN: 1244,
reply_CNAME: 5265,
reply_IP: 25635,
reply_DOMAIN: 97,
reply_RRNAME: 4,
reply_SERVFAIL: 28,
reply_REFUSED: 0,
reply_NOTIMP: 0,
reply_OTHER: 0,
reply_DNSSEC: 0,
reply_NONE: 0,
reply_BLOB: 377,
dns_queries_all_replies: 36910,
privacy_level: 0,
status: 'enabled',
gravity_last_updated: {
file_exists: true,
absolute: 1682216493,
relative: { days: 5, hours: 17, minutes: 52 },
},
});
expect(errorLogSpy).not.toHaveBeenCalled();
expect(warningLogSpy).not.toHaveBeenCalled();
errorLogSpy.mockRestore();
});
it('enable - return true when state change is as expected', async () => {
// arrange
const errorLogSpy = vi.spyOn(Consola, 'error');
const warningLogSpy = vi.spyOn(Consola, 'warn');
let calledCount = 0;
fetchMock.mockResponse((request) => {
if (request.url === 'http://pi.hole/admin/api.php?enable&auth=nice') {
calledCount += 1;
return JSON.stringify({
status: 'enabled',
});
}
return Promise.reject(new Error(`Bad url: ${request.url}`));
});
const client = new PiHoleClient('http://pi.hole', 'nice');
// Act
const summary = await client.enable();
// Assert
expect(summary).toBe(true);
expect(calledCount).toBe(1);
expect(errorLogSpy).not.toHaveBeenCalled();
expect(warningLogSpy).not.toHaveBeenCalled();
errorLogSpy.mockRestore();
});
it('enable - return false when state change is not as expected', async () => {
// arrange
const errorLogSpy = vi.spyOn(Consola, 'error');
const warningLogSpy = vi.spyOn(Consola, 'warn');
let calledCount = 0;
fetchMock.mockResponse((request) => {
if (request.url === 'http://pi.hole/admin/api.php?enable&auth=nice') {
calledCount += 1;
return JSON.stringify({
status: 'disabled',
});
}
return Promise.reject(new Error(`Bad url: ${request.url}`));
});
const client = new PiHoleClient('http://pi.hole', 'nice');
// Act
const summary = await client.enable();
// Assert
expect(summary).toBe(false);
expect(calledCount).toBe(1);
expect(errorLogSpy).not.toHaveBeenCalled();
expect(warningLogSpy).not.toHaveBeenCalled();
errorLogSpy.mockRestore();
});
it('disable - return true when state change is as expected', async () => {
// arrange
const errorLogSpy = vi.spyOn(Consola, 'error');
const warningLogSpy = vi.spyOn(Consola, 'warn');
let calledCount = 0;
fetchMock.mockResponse((request) => {
if (request.url === 'http://pi.hole/admin/api.php?disable&auth=nice') {
calledCount += 1;
return JSON.stringify({
status: 'disabled',
});
}
return Promise.reject(new Error(`Bad url: ${request.url}`));
});
const client = new PiHoleClient('http://pi.hole', 'nice');
// Act
const summary = await client.disable();
// Assert
expect(summary).toBe(true);
expect(calledCount).toBe(1);
expect(errorLogSpy).not.toHaveBeenCalled();
expect(warningLogSpy).not.toHaveBeenCalled();
errorLogSpy.mockRestore();
});
it('disable - return false when state change is not as expected', async () => {
// arrange
const errorLogSpy = vi.spyOn(Consola, 'error');
const warningLogSpy = vi.spyOn(Consola, 'warn');
let calledCount = 0;
fetchMock.mockResponse((request) => {
if (request.url === 'http://pi.hole/admin/api.php?disable&auth=nice') {
calledCount += 1;
return JSON.stringify({
status: 'enabled',
});
}
return Promise.reject(new Error(`Bad url: ${request.url}`));
});
const client = new PiHoleClient('http://pi.hole', 'nice');
// Act
const summary = await client.disable();
// Assert
expect(summary).toBe(false);
expect(calledCount).toBe(1);
expect(errorLogSpy).not.toHaveBeenCalled();
expect(warningLogSpy).not.toHaveBeenCalled();
errorLogSpy.mockRestore();
});
it('disable - throw error when status code does not indicate success', async () => {
// arrange
const errorLogSpy = vi.spyOn(Consola, 'error');
const warningLogSpy = vi.spyOn(Consola, 'warn');
let calledCount = 0;
fetchMock.mockResponse((request) => {
if (request.url === 'http://pi.hole/admin/api.php?disable&auth=nice') {
calledCount += 1;
return {
status: 404,
};
}
return Promise.reject(new Error(`Bad url: ${request.url}`));
});
const client = new PiHoleClient('http://pi.hole', 'nice');
// Act & Assert
await expect(() => client.disable()).rejects.toThrowErrorMatchingInlineSnapshot('"Status code does not indicate success: 404"');
expect(calledCount).toBe(1);
expect(errorLogSpy).not.toHaveBeenCalled();
expect(warningLogSpy).not.toHaveBeenCalled();
errorLogSpy.mockRestore();
});
it('disable - throw error when response is empty', async () => {
// arrange
const errorLogSpy = vi.spyOn(Consola, 'error');
const warningLogSpy = vi.spyOn(Consola, 'warn');
let calledCount = 0;
fetchMock.mockResponse((request) => {
if (request.url === 'http://pi.hole/admin/api.php?disable&auth=nice') {
calledCount += 1;
return JSON.stringify([]);
}
return Promise.reject(new Error(`Bad url: ${request.url}`));
});
const client = new PiHoleClient('http://pi.hole', 'nice');
// Act & Assert
await expect(() => client.disable()).rejects.toThrowErrorMatchingInlineSnapshot('"Response does not indicate success. Authentication is most likely invalid: "');
expect(calledCount).toBe(1);
expect(errorLogSpy).not.toHaveBeenCalled();
expect(warningLogSpy).not.toHaveBeenCalled();
errorLogSpy.mockRestore();
});
});

View File

@@ -0,0 +1,74 @@
import { PiHoleApiStatusChangeResponse, PiHoleApiSummaryResponse } from './piHole.type';
export class PiHoleClient {
private readonly baseHostName: string;
constructor(hostname: string, private readonly apiToken: string) {
this.baseHostName = this.trimStringEnding(hostname, ['/admin/index.php', '/admin', '/']);
}
async getSummary() {
const response = await fetch(
new URL(`${this.baseHostName}/admin/api.php?summaryRaw&auth=${this.apiToken}`)
);
if (response.status !== 200) {
throw new Error(`Status code does not indicate success: ${response.status}`);
}
const json = await response.json();
if (Array.isArray(json)) {
throw new Error(
`Response does not indicate success. Authentication is most likely invalid: ${json}`
);
}
return json as PiHoleApiSummaryResponse;
}
async enable() {
const response = await this.sendStatusChangeRequest('enable');
return response.status === 'enabled';
}
async disable() {
const response = await this.sendStatusChangeRequest('disable');
return response.status === 'disabled';
}
private async sendStatusChangeRequest(
action: 'enable' | 'disable'
): Promise<PiHoleApiStatusChangeResponse> {
const response = await fetch(
`${this.baseHostName}/admin/api.php?${action}&auth=${this.apiToken}`
);
if (response.status !== 200) {
return Promise.reject(new Error(`Status code does not indicate success: ${response.status}`));
}
const json = await response.json();
if (Array.isArray(json)) {
return Promise.reject(
new Error(
`Response does not indicate success. Authentication is most likely invalid: ${json}`
)
);
}
return json as PiHoleApiStatusChangeResponse;
}
private trimStringEnding(original: string, toTrimIfExists: string[]) {
for (let i = 0; i < toTrimIfExists.length; i += 1) {
if (!original.endsWith(toTrimIfExists[i])) {
continue;
}
return original.substring(0, original.indexOf(toTrimIfExists[i]));
}
return original;
}
}

View File

@@ -0,0 +1,38 @@
export type PiHoleApiSummaryResponse = {
domains_being_blocked: number;
dns_queries_today: number;
ads_blocked_today: number;
ads_percentage_today: number;
unique_domains: number;
queries_forwarded: number;
queries_cached: number;
clients_ever_seen: number;
unique_clients: number;
dns_queries_all_types: number;
reply_UNKNOWN: number;
reply_NODATA: number;
reply_NXDOMAIN: number;
reply_CNAME: number;
reply_IP: number;
reply_DOMAIN: number;
reply_RRNAME: number;
reply_SERVFAIL: number;
reply_REFUSED: number;
reply_NOTIMP: number;
reply_OTHER: number;
reply_DNSSEC: number;
reply_NONE: number;
reply_BLOB: number;
dns_queries_all_replies: number;
privacy_level: number;
status: 'enabled' | 'disabled';
gravity_last_updated: {
file_exists: boolean;
absolute: number;
relative: { days: number; hours: number; minutes: number };
};
};
export type PiHoleApiStatusChangeResponse = {
status: 'enabled' | 'disabled';
};

View File

@@ -38,6 +38,8 @@ export const dashboardNamespaces = [
'modules/video-stream', 'modules/video-stream',
'modules/media-requests-list', 'modules/media-requests-list',
'modules/media-requests-stats', 'modules/media-requests-stats',
'modules/dns-hole-summary',
'modules/dns-hole-controls',
'widgets/error-boundary', 'widgets/error-boundary',
]; ];

View File

@@ -44,7 +44,8 @@ export type IntegrationType =
| 'transmission' | 'transmission'
| 'plex' | 'plex'
| 'jellyfin' | 'jellyfin'
| 'nzbGet'; | 'nzbGet'
| 'pihole';
export type AppIntegrationType = { export type AppIntegrationType = {
type: IntegrationType | null; type: IntegrationType | null;
@@ -84,6 +85,7 @@ export const integrationFieldProperties: {
transmission: ['username', 'password'], transmission: ['username', 'password'],
jellyfin: ['username', 'password'], jellyfin: ['username', 'password'],
plex: ['apiKey'], plex: ['apiKey'],
pihole: ['password'],
}; };
export type IntegrationFieldDefinitionType = { export type IntegrationFieldDefinitionType = {

View File

@@ -0,0 +1,120 @@
import { Badge, Box, Button, Card, Group, Image, Stack, Text } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { IconDeviceGamepad, IconPlayerPlay, IconPlayerStop } from '@tabler/icons';
import { useConfigContext } from '../../config/provider';
import { defineWidget } from '../helper';
import { WidgetLoading } from '../loading';
import { IWidget } from '../widgets';
import { useDnsHoleControlMutation, useDnsHoleSummeryQuery } from './query';
import { PiholeApiSummaryType } from './type';
import { queryClient } from '../../tools/server/configurations/tanstack/queryClient.tool';
const definition = defineWidget({
id: 'dns-hole-controls',
icon: IconDeviceGamepad,
options: {},
gridstack: {
minWidth: 3,
minHeight: 2,
maxWidth: 12,
maxHeight: 12,
},
component: DnsHoleControlsWidgetTile,
});
export type IDnsHoleControlsWidget = IWidget<(typeof definition)['id'], typeof definition>;
interface DnsHoleControlsWidgetProps {
widget: IDnsHoleControlsWidget;
}
function DnsHoleControlsWidgetTile({ widget }: DnsHoleControlsWidgetProps) {
const { isInitialLoading, data, refetch } = useDnsHoleSummeryQuery();
const { mutateAsync } = useDnsHoleControlMutation();
const { t } = useTranslation('modules/dns-hole-controls');
const { config } = useConfigContext();
if (isInitialLoading || !data) {
return <WidgetLoading />;
}
return (
<Stack>
<Group grow>
<Button
onClick={async () => {
await mutateAsync('enabled');
await queryClient.invalidateQueries({ queryKey: ['dns-hole-summary'] });
}}
leftIcon={<IconPlayerPlay size={20} />}
variant="light"
color="green"
>
{t('card.buttons.enableAll')}
</Button>
<Button
onClick={async () => {
await mutateAsync('disabled');
await queryClient.invalidateQueries({ queryKey: ['dns-hole-summary'] });
}}
leftIcon={<IconPlayerStop size={20} />}
variant="light"
color="red"
>
{t('card.buttons.disableAll')}
</Button>
</Group>
{data.status.map((status, index) => {
const app = config?.apps.find((x) => x.id === status.appId);
if (!app) {
return null;
}
return (
<Card withBorder key={index} p="xs">
<Group position="apart">
<Group>
<Box
sx={(theme) => ({
backgroundColor:
theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[2],
textAlign: 'center',
padding: 5,
borderRadius: theme.radius.md,
})}
>
<Image src={app.appearance.iconUrl} width={25} height={25} fit="contain" />
</Box>
<Text>{app.name}</Text>
</Group>
<StatusBadge status={status.status} />
</Group>
</Card>
);
})}
</Stack>
);
}
const StatusBadge = ({ status }: { status: PiholeApiSummaryType['status'] }) => {
const { t } = useTranslation('modules/dns-hole-controls');
if (status === 'enabled') {
return (
<Badge variant="dot" color="green">
{t('card.status.enabled')}
</Badge>
);
}
return (
<Badge variant="dot" color="red">
{t('card.status.disabled')}
</Badge>
);
};
export default definition;

View File

@@ -0,0 +1,181 @@
import { useTranslation } from 'next-i18next';
import { Card, Center, Container, Stack, Text } from '@mantine/core';
import { IconAd, IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from '@tabler/icons';
import { defineWidget } from '../helper';
import { WidgetLoading } from '../loading';
import { IWidget } from '../widgets';
import { formatNumber } from '../../tools/client/math';
import { useDnsHoleSummeryQuery } from './query';
const definition = defineWidget({
id: 'dns-hole-summary',
icon: IconAd,
options: {
usePiHoleColors: {
type: 'switch',
defaultValue: true,
},
},
gridstack: {
minWidth: 2,
minHeight: 2,
maxWidth: 12,
maxHeight: 12,
},
component: DnsHoleSummaryWidgetTile,
});
export type IDnsHoleSummaryWidget = IWidget<(typeof definition)['id'], typeof definition>;
interface DnsHoleSummaryWidgetProps {
widget: IDnsHoleSummaryWidget;
}
function DnsHoleSummaryWidgetTile({ widget }: DnsHoleSummaryWidgetProps) {
const { t } = useTranslation('modules/dns-hole-summary');
const { isInitialLoading, data } = useDnsHoleSummeryQuery();
if (isInitialLoading || !data) {
return <WidgetLoading />;
}
return (
<Container
display="grid"
h="100%"
style={{
gridTemplateColumns: '1fr 1fr',
gridTemplateRows: '1fr 1fr',
marginLeft: -20,
marginRight: -20,
}}
>
<Card
m="xs"
sx={(theme) => {
if (!widget.properties.usePiHoleColors) {
return {};
}
if (theme.colorScheme === 'dark') {
return {
backgroundColor: 'rgba(240, 82, 60, 0.4)',
};
}
return {
backgroundColor: 'rgba(240, 82, 60, 0.2)',
};
}}
withBorder
>
<Center h="100%">
<Stack align="center" spacing="xs">
<IconBarrierBlock size={30} />
<div>
<Text align="center">{formatNumber(data.adsBlockedToday, 0)}</Text>
<Text align="center" lh={1.2} size="sm">
{t('card.metrics.queriesBlockedToday')}
</Text>
</div>
</Stack>
</Center>
</Card>
<Card
m="xs"
sx={(theme) => {
if (!widget.properties.usePiHoleColors) {
return {};
}
if (theme.colorScheme === 'dark') {
return {
backgroundColor: 'rgba(255, 165, 20, 0.4)',
};
}
return {
backgroundColor: 'rgba(255, 165, 20, 0.4)',
};
}}
withBorder
>
<Center h="100%">
<Stack align="center" spacing="xs">
<IconPercentage size={30} />
<div>
<Text align="center">{(data.adsBlockedTodayPercentage * 100).toFixed(2)}%</Text>
<Text align="center" lh={1.2} size="sm">
{t('card.metrics.queriesBlockedTodayPercentage')}
</Text>
</div>
</Stack>
</Center>
</Card>
<Card
m="xs"
sx={(theme) => {
if (!widget.properties.usePiHoleColors) {
return {};
}
if (theme.colorScheme === 'dark') {
return {
backgroundColor: 'rgba(0, 175, 218, 0.4)',
};
}
return {
backgroundColor: 'rgba(0, 175, 218, 0.4)',
};
}}
withBorder
>
<Center h="100%">
<Stack align="center" spacing="xs">
<IconSearch size={30} />
<div>
<Text align="center">{formatNumber(data.dnsQueriesToday, 0)}</Text>
<Text align="center" lh={1.2} size="sm">
{t('card.metrics.queriesToday')}
</Text>
</div>
</Stack>
</Center>
</Card>
<Card
m="xs"
sx={(theme) => {
if (!widget.properties.usePiHoleColors) {
return {};
}
if (theme.colorScheme === 'dark') {
return {
backgroundColor: 'rgba(0, 176, 96, 0.4)',
};
}
return {
backgroundColor: 'rgba(0, 176, 96, 0.4)',
};
}}
withBorder
>
<Center h="100%">
<Stack align="center" spacing="xs">
<IconWorldWww size={30} />
<div>
<Text align="center">{formatNumber(data.domainsBeingBlocked, 0)}</Text>
<Text align="center" lh={1.2} size="sm">
{t('card.metrics.domainsOnAdlist')}
</Text>
</div>
</Stack>
</Center>
</Card>
</Container>
);
}
export default definition;

View File

@@ -0,0 +1,23 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { AdStatistics, PiholeApiSummaryType } from './type';
export const useDnsHoleSummeryQuery = () =>
useQuery({
queryKey: ['dns-hole-summary'],
queryFn: async () => {
const response = await fetch('/api/modules/dns-hole/summary');
return (await response.json()) as AdStatistics;
},
refetchInterval: 3 * 60 * 1000,
});
export const useDnsHoleControlMutation = () =>
useMutation({
mutationKey: ['dns-hole-control'],
mutationFn: async (status: PiholeApiSummaryType['status']) => {
const response = await fetch(`/api/modules/dns-hole/control?status=${status}`, {
method: 'POST',
});
return response.json();
},
});

View File

@@ -0,0 +1,45 @@
export type AdStatistics = {
domainsBeingBlocked: number;
adsBlockedToday: number;
adsBlockedTodayPercentage: number;
dnsQueriesToday: number;
status: {
status: PiholeApiSummaryType['status'],
appId: string;
}[];
};
export type PiholeApiSummaryType = {
domains_being_blocked: number;
dns_queries_today: number;
ads_blocked_today: number;
ads_percentage_today: number;
unique_domains: number;
queries_forwarded: number;
queries_cached: number;
clients_ever_seen: number;
unique_clients: number;
dns_queries_all_types: number;
reply_UNKNOWN: number;
reply_NODATA: number;
reply_NXDOMAIN: number;
reply_CNAME: number;
reply_IP: number;
reply_DOMAIN: number;
reply_RRNAME: number;
reply_SERVFAIL: number;
reply_REFUSED: number;
reply_NOTIMP: number;
reply_OTHER: number;
reply_DNSSEC: number;
reply_NONE: number;
reply_BLOB: number;
dns_queries_all_replies: number;
privacy_level: number;
status: 'enabled' | 'disabled';
gravity_last_updated: {
file_exists: boolean;
absolute: number;
relative: { days: number; hours: number; minutes: number };
};
};

View File

@@ -11,6 +11,8 @@ import videoStream from './video/VideoStreamTile';
import weather from './weather/WeatherTile'; import weather from './weather/WeatherTile';
import mediaRequestsList from './media-requests/MediaRequestListTile'; import mediaRequestsList from './media-requests/MediaRequestListTile';
import mediaRequestsStats from './media-requests/MediaRequestStatsTile'; import mediaRequestsStats from './media-requests/MediaRequestStatsTile';
import dnsHoleSummary from './dnshole/DnsHoleSummary';
import dnsHoleControls from './dnshole/DnsHoleControls';
export default { export default {
calendar, calendar,
@@ -26,4 +28,6 @@ export default {
'media-server': mediaServer, 'media-server': mediaServer,
'media-requests-list': mediaRequestsList, 'media-requests-list': mediaRequestsList,
'media-requests-stats': mediaRequestsStats, 'media-requests-stats': mediaRequestsStats,
'dns-hole-summary': dnsHoleSummary,
'dns-hole-controls': dnsHoleControls,
}; };