diff --git a/public/locales/en/modules/dns-hole-controls.json b/public/locales/en/modules/dns-hole-controls.json new file mode 100644 index 000000000..4bd95585d --- /dev/null +++ b/public/locales/en/modules/dns-hole-controls.json @@ -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" + } + } +} diff --git a/public/locales/en/modules/dns-hole-summary.json b/public/locales/en/modules/dns-hole-summary.json new file mode 100644 index 000000000..2cdaec0f5 --- /dev/null +++ b/public/locales/en/modules/dns-hole-summary.json @@ -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" + } + } +} diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx index cbb74405f..2f299e4e8 100644 --- a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx +++ b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx @@ -85,6 +85,11 @@ export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => { image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png', 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)); const getNewProperties = (value: string | null): AppIntegrationPropertyType[] => { diff --git a/src/components/Dashboard/Modals/SelectElement/Components/Overview/AvailableElementsOverview.tsx b/src/components/Dashboard/Modals/SelectElement/Components/Overview/AvailableElementsOverview.tsx index 653c86ec0..2be8867b6 100644 --- a/src/components/Dashboard/Modals/SelectElement/Components/Overview/AvailableElementsOverview.tsx +++ b/src/components/Dashboard/Modals/SelectElement/Components/Overview/AvailableElementsOverview.tsx @@ -131,11 +131,6 @@ export const AvailableElementTypes = ({ icon={} onClick={onClickCreateCategory} /> - {/*} - onClick={onOpenStaticElements} - />*/} ); diff --git a/src/components/Dashboard/Modals/SelectElement/Components/Shared/GenericElementType.tsx b/src/components/Dashboard/Modals/SelectElement/Components/Shared/GenericElementType.tsx index cde3d46e4..5a0f3c5aa 100644 --- a/src/components/Dashboard/Modals/SelectElement/Components/Shared/GenericElementType.tsx +++ b/src/components/Dashboard/Modals/SelectElement/Components/Shared/GenericElementType.tsx @@ -29,7 +29,7 @@ export const GenericAvailableElementType = ({ : image; return ( - + diff --git a/src/components/Dashboard/Wrappers/WrapperContent.tsx b/src/components/Dashboard/Wrappers/WrapperContent.tsx index 12927e588..f35250045 100644 --- a/src/components/Dashboard/Wrappers/WrapperContent.tsx +++ b/src/components/Dashboard/Wrappers/WrapperContent.tsx @@ -2,8 +2,8 @@ import { GridStack } from 'fily-publish-gridstack'; import { MutableRefObject, RefObject } from 'react'; import { AppType } from '../../../types/app'; import Widgets from '../../../widgets'; -import { IWidget, IWidgetDefinition } from '../../../widgets/widgets'; import { WidgetWrapper } from '../../../widgets/WidgetWrapper'; +import { IWidget, IWidgetDefinition } from '../../../widgets/widgets'; import { appTileDefinition } from '../Tiles/Apps/AppTile'; import { GridstackTileWrapper } from '../Tiles/TileWrapper'; import { useGridstackStore } from './gridstack/store'; diff --git a/src/pages/api/configs/[slug].ts b/src/pages/api/configs/[slug].ts index e7f37f4ef..c33cfad20 100644 --- a/src/pages/api/configs/[slug].ts +++ b/src/pages/api/configs/[slug].ts @@ -6,9 +6,8 @@ import Consola from 'consola'; import { NextApiRequest, NextApiResponse } from 'next'; -import { BackendConfigType, ConfigType } from '../../../types/config'; import { getConfig } from '../../../tools/config/getConfig'; -import widgets from '../../../widgets'; +import { BackendConfigType, ConfigType } from '../../../types/config'; import { IRssWidget } from '../../../widgets/rss/RssWidgetTile'; function Put(req: NextApiRequest, res: NextApiResponse) { diff --git a/src/pages/api/modules/dns-hole/control.ts b/src/pages/api/modules/dns-hole/control.ts new file mode 100644 index 000000000..360b7b6dc --- /dev/null +++ b/src/pages/api/modules/dns-hole/control.ts @@ -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({}); +}; diff --git a/src/pages/api/modules/dns-hole/summary.spec.ts b/src/pages/api/modules/dns-hole/summary.spec.ts new file mode 100644 index 000000000..e80999a3e --- /dev/null +++ b/src/pages/api/modules/dns-hole/summary.spec.ts @@ -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(); + }); +}); diff --git a/src/pages/api/modules/dns-hole/summary.ts b/src/pages/api/modules/dns-hole/summary.ts new file mode 100644 index 000000000..5903ed44f --- /dev/null +++ b/src/pages/api/modules/dns-hole/summary.ts @@ -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); +}; diff --git a/src/tools/client/app-properties.ts b/src/tools/client/app-properties.ts new file mode 100644 index 000000000..a53b38dbf --- /dev/null +++ b/src/tools/client/app-properties.ts @@ -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 ?? ''; diff --git a/src/tools/client/math.ts b/src/tools/client/math.ts new file mode 100644 index 000000000..42f543b54 --- /dev/null +++ b/src/tools/client/math.ts @@ -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); +}; diff --git a/src/tools/server/sdk/pihole/piHole.spec.ts b/src/tools/server/sdk/pihole/piHole.spec.ts new file mode 100644 index 000000000..36a1f1aeb --- /dev/null +++ b/src/tools/server/sdk/pihole/piHole.spec.ts @@ -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(); + }); +}); diff --git a/src/tools/server/sdk/pihole/piHole.ts b/src/tools/server/sdk/pihole/piHole.ts new file mode 100644 index 000000000..38d24a2a1 --- /dev/null +++ b/src/tools/server/sdk/pihole/piHole.ts @@ -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 { + 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; + } +} diff --git a/src/tools/server/sdk/pihole/piHole.type.ts b/src/tools/server/sdk/pihole/piHole.type.ts new file mode 100644 index 000000000..134dcd87f --- /dev/null +++ b/src/tools/server/sdk/pihole/piHole.type.ts @@ -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'; +}; diff --git a/src/tools/server/translation-namespaces.ts b/src/tools/server/translation-namespaces.ts index 8a2d3ad13..233d4f59b 100644 --- a/src/tools/server/translation-namespaces.ts +++ b/src/tools/server/translation-namespaces.ts @@ -38,6 +38,8 @@ export const dashboardNamespaces = [ 'modules/video-stream', 'modules/media-requests-list', 'modules/media-requests-stats', + 'modules/dns-hole-summary', + 'modules/dns-hole-controls', 'widgets/error-boundary', ]; diff --git a/src/types/app.ts b/src/types/app.ts index ad951539f..94e4b5cba 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -44,7 +44,8 @@ export type IntegrationType = | 'transmission' | 'plex' | 'jellyfin' - | 'nzbGet'; + | 'nzbGet' + | 'pihole'; export type AppIntegrationType = { type: IntegrationType | null; @@ -84,6 +85,7 @@ export const integrationFieldProperties: { transmission: ['username', 'password'], jellyfin: ['username', 'password'], plex: ['apiKey'], + pihole: ['password'], }; export type IntegrationFieldDefinitionType = { diff --git a/src/widgets/dnshole/DnsHoleControls.tsx b/src/widgets/dnshole/DnsHoleControls.tsx new file mode 100644 index 000000000..19bad810a --- /dev/null +++ b/src/widgets/dnshole/DnsHoleControls.tsx @@ -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 ; + } + + return ( + + + + + + + {data.status.map((status, index) => { + const app = config?.apps.find((x) => x.id === status.appId); + + if (!app) { + return null; + } + + return ( + + + + ({ + backgroundColor: + theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[2], + textAlign: 'center', + padding: 5, + borderRadius: theme.radius.md, + })} + > + + + {app.name} + + + + + + ); + })} + + ); +} + +const StatusBadge = ({ status }: { status: PiholeApiSummaryType['status'] }) => { + const { t } = useTranslation('modules/dns-hole-controls'); + if (status === 'enabled') { + return ( + + {t('card.status.enabled')} + + ); + } + + return ( + + {t('card.status.disabled')} + + ); +}; + +export default definition; diff --git a/src/widgets/dnshole/DnsHoleSummary.tsx b/src/widgets/dnshole/DnsHoleSummary.tsx new file mode 100644 index 000000000..08bd0763b --- /dev/null +++ b/src/widgets/dnshole/DnsHoleSummary.tsx @@ -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 ; + } + + return ( + + { + 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 + > +
+ + +
+ {formatNumber(data.adsBlockedToday, 0)} + + {t('card.metrics.queriesBlockedToday')} + +
+
+
+
+ { + 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 + > +
+ + +
+ {(data.adsBlockedTodayPercentage * 100).toFixed(2)}% + + {t('card.metrics.queriesBlockedTodayPercentage')} + +
+
+
+
+ { + 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 + > +
+ + +
+ {formatNumber(data.dnsQueriesToday, 0)} + + {t('card.metrics.queriesToday')} + +
+
+
+
+ { + 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 + > +
+ + +
+ {formatNumber(data.domainsBeingBlocked, 0)} + + {t('card.metrics.domainsOnAdlist')} + +
+
+
+
+
+ ); +} + +export default definition; diff --git a/src/widgets/dnshole/query.ts b/src/widgets/dnshole/query.ts new file mode 100644 index 000000000..a38a55d03 --- /dev/null +++ b/src/widgets/dnshole/query.ts @@ -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(); + }, + }); diff --git a/src/widgets/dnshole/type.ts b/src/widgets/dnshole/type.ts new file mode 100644 index 000000000..2ad87d652 --- /dev/null +++ b/src/widgets/dnshole/type.ts @@ -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 }; + }; +}; diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 4250bfd07..a80026c84 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -11,6 +11,8 @@ import videoStream from './video/VideoStreamTile'; import weather from './weather/WeatherTile'; import mediaRequestsList from './media-requests/MediaRequestListTile'; import mediaRequestsStats from './media-requests/MediaRequestStatsTile'; +import dnsHoleSummary from './dnshole/DnsHoleSummary'; +import dnsHoleControls from './dnshole/DnsHoleControls'; export default { calendar, @@ -26,4 +28,6 @@ export default { 'media-server': mediaServer, 'media-requests-list': mediaRequestsList, 'media-requests-stats': mediaRequestsStats, + 'dns-hole-summary': dnsHoleSummary, + 'dns-hole-controls': dnsHoleControls, };