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',
|
||||
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[] => {
|
||||
|
||||
@@ -131,11 +131,6 @@ export const AvailableElementTypes = ({
|
||||
icon={<IconBoxAlignTop size={40} strokeWidth={1.3} />}
|
||||
onClick={onClickCreateCategory}
|
||||
/>
|
||||
{/*<ElementItem
|
||||
name="Static Element"
|
||||
icon={<IconTextResize size={40} strokeWidth={1.3} />}
|
||||
onClick={onOpenStaticElements}
|
||||
/>*/}
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ export const GenericAvailableElementType = ({
|
||||
: image;
|
||||
|
||||
return (
|
||||
<Grid.Col span="auto">
|
||||
<Grid.Col xs={12} sm={4} md={3}>
|
||||
<Card style={{ height: '100%' }}>
|
||||
<Stack justify="space-between" style={{ height: '100%' }}>
|
||||
<Stack spacing="xs">
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
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/media-requests-list',
|
||||
'modules/media-requests-stats',
|
||||
'modules/dns-hole-summary',
|
||||
'modules/dns-hole-controls',
|
||||
'widgets/error-boundary',
|
||||
];
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
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 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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user