mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-10 07:25:48 +01:00
✨ 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:
16
public/locales/en/modules/dns-hole-controls.json
Normal file
16
public/locales/en/modules/dns-hole-controls.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
public/locales/en/modules/dns-hole-summary.json
Normal file
21
public/locales/en/modules/dns-hole-summary.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[] => {
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
53
src/pages/api/modules/dns-hole/control.ts
Normal file
53
src/pages/api/modules/dns-hole/control.ts
Normal 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({});
|
||||||
|
};
|
||||||
273
src/pages/api/modules/dns-hole/summary.spec.ts
Normal file
273
src/pages/api/modules/dns-hole/summary.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
60
src/pages/api/modules/dns-hole/summary.ts
Normal file
60
src/pages/api/modules/dns-hole/summary.ts
Normal 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);
|
||||||
|
};
|
||||||
4
src/tools/client/app-properties.ts
Normal file
4
src/tools/client/app-properties.ts
Normal 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
18
src/tools/client/math.ts
Normal 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);
|
||||||
|
};
|
||||||
346
src/tools/server/sdk/pihole/piHole.spec.ts
Normal file
346
src/tools/server/sdk/pihole/piHole.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
74
src/tools/server/sdk/pihole/piHole.ts
Normal file
74
src/tools/server/sdk/pihole/piHole.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/tools/server/sdk/pihole/piHole.type.ts
Normal file
38
src/tools/server/sdk/pihole/piHole.type.ts
Normal 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';
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
120
src/widgets/dnshole/DnsHoleControls.tsx
Normal file
120
src/widgets/dnshole/DnsHoleControls.tsx
Normal 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;
|
||||||
181
src/widgets/dnshole/DnsHoleSummary.tsx
Normal file
181
src/widgets/dnshole/DnsHoleSummary.tsx
Normal 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;
|
||||||
23
src/widgets/dnshole/query.ts
Normal file
23
src/widgets/dnshole/query.ts
Normal 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();
|
||||||
|
},
|
||||||
|
});
|
||||||
45
src/widgets/dnshole/type.ts
Normal file
45
src/widgets/dnshole/type.ts
Normal 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 };
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user