mirror of
https://github.com/gosticks/web.git
synced 2026-10-06 14:47:32 +00:00
Merge pull request #216 from pi-hole/feature/top-blocked-clients
Top Blocked Clients
This commit is contained in:
@@ -19,5 +19,6 @@
|
||||
"No Domains Found": "No Domains Found",
|
||||
"Queries Answered By Destination": "Queries Answered By Destination",
|
||||
"Queries Over {{range}}": "Queries Over {{range}}",
|
||||
"Clients Over {{range}}": "Clients Over {{range}}"
|
||||
"Clients Over {{range}}": "Clients Over {{range}}",
|
||||
"Top Blocked Clients": "Top Blocked Clients"
|
||||
}
|
||||
+26
-10
@@ -156,10 +156,10 @@ function upstreams(length) {
|
||||
};
|
||||
}
|
||||
|
||||
function topList(length, max, fakeData) {
|
||||
function topDomainList(length, max) {
|
||||
const result = [];
|
||||
const numbers = [];
|
||||
const domains = unique(fakeData, length);
|
||||
const domains = unique(faker.internet.domainName, length);
|
||||
|
||||
for (let i = 0; i < length; i++)
|
||||
numbers.push(faker.random.number({ max: max }));
|
||||
@@ -176,11 +176,11 @@ function topList(length, max, fakeData) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function topBlocked(length) {
|
||||
function topBlockedDomains(length) {
|
||||
const totalQueries = faker.random.number();
|
||||
|
||||
return {
|
||||
top_domains: topList(length, totalQueries, faker.internet.domainName),
|
||||
top_domains: topDomainList(length, totalQueries),
|
||||
blocked_queries: totalQueries
|
||||
};
|
||||
}
|
||||
@@ -189,13 +189,12 @@ function topDomains(length) {
|
||||
const totalQueries = faker.random.number();
|
||||
|
||||
return {
|
||||
top_domains: topList(length, totalQueries, faker.internet.domainName),
|
||||
top_domains: topDomainList(length, totalQueries),
|
||||
total_queries: totalQueries
|
||||
};
|
||||
}
|
||||
|
||||
function topClients(length) {
|
||||
const totalQueries = faker.random.number();
|
||||
function topClientList(length, totalQueries) {
|
||||
const top_clients = [];
|
||||
const numbers = [];
|
||||
const clients = unique(faker.internet.ip, length);
|
||||
@@ -213,12 +212,27 @@ function topClients(length) {
|
||||
});
|
||||
}
|
||||
|
||||
return top_clients;
|
||||
}
|
||||
|
||||
function topClients(length) {
|
||||
const totalQueries = faker.random.number();
|
||||
|
||||
return {
|
||||
top_clients: top_clients,
|
||||
top_clients: topClientList(length, totalQueries),
|
||||
total_queries: totalQueries
|
||||
};
|
||||
}
|
||||
|
||||
function topBlockedClients(length) {
|
||||
const totalQueries = faker.random.number();
|
||||
|
||||
return {
|
||||
top_clients: topClientList(length, totalQueries),
|
||||
blocked_queries: totalQueries
|
||||
};
|
||||
}
|
||||
|
||||
function clientsOverTime(range, size) {
|
||||
const startDate = pastDate();
|
||||
const clients = unique(faker.internet.ip, size).map(ip => ({
|
||||
@@ -402,17 +416,19 @@ write("public/fakeAPI/stats/summary", summary());
|
||||
write("public/fakeAPI/stats/history", history(5000));
|
||||
write("public/fakeAPI/stats/query_types", queryTypes());
|
||||
write("public/fakeAPI/stats/upstreams", upstreams(3));
|
||||
write("public/fakeAPI/stats/top_blocked", topBlocked(10));
|
||||
write("public/fakeAPI/stats/top_blocked", topBlockedDomains(10));
|
||||
write("public/fakeAPI/stats/top_domains", topDomains(10));
|
||||
write("public/fakeAPI/stats/top_clients", topClients(10));
|
||||
write("public/fakeAPI/stats/top_blocked_clients", topBlockedClients(10));
|
||||
write("public/fakeAPI/stats/database/overTime/history", historyOverTime(144));
|
||||
write("public/fakeAPI/stats/database/overTime/clients", clientsOverTime(144, 5));
|
||||
write("public/fakeAPI/stats/database/summary", summary());
|
||||
write("public/fakeAPI/stats/database/query_types", queryTypes());
|
||||
write("public/fakeAPI/stats/database/upstreams", upstreams(3));
|
||||
write("public/fakeAPI/stats/database/top_blocked", topBlocked(10));
|
||||
write("public/fakeAPI/stats/database/top_blocked", topBlockedDomains(10));
|
||||
write("public/fakeAPI/stats/database/top_domains", topDomains(10));
|
||||
write("public/fakeAPI/stats/database/top_clients", topClients(10));
|
||||
write("public/fakeAPI/stats/database/top_blocked_clients", topBlockedClients(10));
|
||||
write("public/fakeAPI/auth", auth());
|
||||
write("public/fakeAPI/version", getVersionInfo());
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/* Pi-hole: A black hole for Internet advertisements
|
||||
* (c) 2019 Pi-hole, LLC (https://pi-hole.net)
|
||||
* Network-wide ad blocking via your own hardware.
|
||||
*
|
||||
* Web Interface
|
||||
* Top Blocked Clients component
|
||||
*
|
||||
* This file is copyright under the latest version of the EUPL.
|
||||
* Please see LICENSE file for your rights under this license. */
|
||||
|
||||
import React, { ReactNode } from "react";
|
||||
import { WithNamespaces, withNamespaces } from "react-i18next";
|
||||
import api from "../../util/api";
|
||||
import TopTable from "./TopTable";
|
||||
import i18next from "i18next";
|
||||
import { TimeRangeContext } from "../common/context/TimeRangeContext";
|
||||
|
||||
export interface TopBlockedClientsData {
|
||||
blockedQueries: number;
|
||||
topClients: Array<ApiClient>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the API data into the form used in generateRows
|
||||
*
|
||||
* @param data The API data
|
||||
* @returns The parsed data
|
||||
*/
|
||||
export const transformData = (
|
||||
data: ApiTopBlockedClients
|
||||
): TopBlockedClientsData => ({
|
||||
blockedQueries: data.blocked_queries,
|
||||
topClients: data.top_clients
|
||||
});
|
||||
|
||||
/**
|
||||
* Generate rows of top blocked clients
|
||||
*
|
||||
* @param t The translation function
|
||||
* @returns Rows of top blocked clients
|
||||
*/
|
||||
export const generateRows = (t: i18next.TranslationFunction) => (
|
||||
data: TopBlockedClientsData
|
||||
): ReactNode => {
|
||||
return data.topClients.map(item => {
|
||||
const percentage = (item.count / data.blockedQueries) * 100;
|
||||
|
||||
return (
|
||||
<tr key={item.name + "|" + item.ip}>
|
||||
<td>{item.name !== "" ? item.name : item.ip}</td>
|
||||
<td>{item.count.toLocaleString()}</td>
|
||||
<td style={{ verticalAlign: "middle" }}>
|
||||
<div
|
||||
className="progress"
|
||||
title={t("{{percent}}% of {{total}}", {
|
||||
percent: percentage.toFixed(1),
|
||||
total: data.blockedQueries.toLocaleString()
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="progress-bar bg-danger"
|
||||
style={{ width: percentage + "%" }}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const TopBlockedClients = ({
|
||||
apiCall,
|
||||
t,
|
||||
...props
|
||||
}: WithNamespaces & { apiCall: () => Promise<ApiTopBlockedClients> }) => (
|
||||
<TopTable
|
||||
{...props}
|
||||
title={t("Top Blocked Clients")}
|
||||
initialData={{
|
||||
blockedQueries: 0,
|
||||
topClients: []
|
||||
}}
|
||||
headers={[t("Client"), t("Requests"), t("Frequency")]}
|
||||
emptyMessage={t("No Clients Found")}
|
||||
isEmpty={data => data.topClients.length === 0}
|
||||
apiCall={apiCall}
|
||||
apiHandler={transformData}
|
||||
generateRows={generateRows(t)}
|
||||
/>
|
||||
);
|
||||
|
||||
const TopBlockedClientsContainer = (props: WithNamespaces) => (
|
||||
<TimeRangeContext.Consumer>
|
||||
{context => (
|
||||
<TopBlockedClients
|
||||
{...props}
|
||||
apiCall={() =>
|
||||
context.range
|
||||
? api.getTopBlockedClientsDb(context.range)
|
||||
: api.getTopBlockedClients()
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TimeRangeContext.Consumer>
|
||||
);
|
||||
|
||||
export default withNamespaces(["common", "dashboard"])(
|
||||
TopBlockedClientsContainer
|
||||
);
|
||||
+14
-10
@@ -15,7 +15,7 @@ import TopTable from "./TopTable";
|
||||
import i18next from "i18next";
|
||||
import { TimeRangeContext } from "../common/context/TimeRangeContext";
|
||||
|
||||
export interface TopBlockedData {
|
||||
export interface TopBlockedDomainsData {
|
||||
totalBlocked: number;
|
||||
topBlocked: Array<ApiTopDomainItem>;
|
||||
}
|
||||
@@ -26,7 +26,9 @@ export interface TopBlockedData {
|
||||
* @param data the API data
|
||||
* @returns {{totalBlocked: number, topBlocked: *}} the parsed data
|
||||
*/
|
||||
export const transformData = (data: ApiTopBlocked): TopBlockedData => ({
|
||||
export const transformData = (
|
||||
data: ApiTopBlockedDomains
|
||||
): TopBlockedDomainsData => ({
|
||||
totalBlocked: data.blocked_queries,
|
||||
topBlocked: data.top_domains
|
||||
});
|
||||
@@ -38,7 +40,7 @@ export const transformData = (data: ApiTopBlocked): TopBlockedData => ({
|
||||
* @returns {function(*): any[]} a function to generate rows of top blocked
|
||||
*/
|
||||
export const generateRows = (t: i18next.TranslationFunction) => (
|
||||
data: TopBlockedData
|
||||
data: TopBlockedDomainsData
|
||||
) => {
|
||||
return data.topBlocked.map(item => {
|
||||
const percentage = (item.count / data.totalBlocked) * 100;
|
||||
@@ -66,11 +68,11 @@ export const generateRows = (t: i18next.TranslationFunction) => (
|
||||
});
|
||||
};
|
||||
|
||||
const TopBlocked = ({
|
||||
const TopBlockedDomains = ({
|
||||
apiCall,
|
||||
t,
|
||||
...props
|
||||
}: WithNamespaces & { apiCall: () => Promise<ApiTopBlocked> }) => (
|
||||
}: WithNamespaces & { apiCall: () => Promise<ApiTopBlockedDomains> }) => (
|
||||
<TopTable
|
||||
{...props}
|
||||
title={t("Top Blocked Domains")}
|
||||
@@ -87,19 +89,21 @@ const TopBlocked = ({
|
||||
/>
|
||||
);
|
||||
|
||||
const TopBlockedContainer = (props: WithNamespaces) => (
|
||||
const TopBlockedDomainsContainer = (props: WithNamespaces) => (
|
||||
<TimeRangeContext.Consumer>
|
||||
{context => (
|
||||
<TopBlocked
|
||||
<TopBlockedDomains
|
||||
{...props}
|
||||
apiCall={() =>
|
||||
context.range
|
||||
? api.getTopBlockedDb(context.range)
|
||||
: api.getTopBlocked()
|
||||
? api.getTopBlockedDomainsDb(context.range)
|
||||
: api.getTopBlockedDomains()
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TimeRangeContext.Consumer>
|
||||
);
|
||||
|
||||
export default withNamespaces(["common", "dashboard"])(TopBlockedContainer);
|
||||
export default withNamespaces(["common", "dashboard"])(
|
||||
TopBlockedDomainsContainer
|
||||
);
|
||||
@@ -0,0 +1,41 @@
|
||||
/* Pi-hole: A black hole for Internet advertisements
|
||||
* (c) 2019 Pi-hole, LLC (https://pi-hole.net)
|
||||
* Network-wide ad blocking via your own hardware.
|
||||
*
|
||||
* Web Interface
|
||||
* TopBlockedClients component test
|
||||
*
|
||||
* This file is copyright under the latest version of the EUPL.
|
||||
* Please see LICENSE file for your rights under this license. */
|
||||
|
||||
import React from "react";
|
||||
import { generateRows, transformData } from "../TopBlockedClients";
|
||||
|
||||
const fakeData: ApiTopBlockedClients = {
|
||||
top_clients: [
|
||||
{ name: "", ip: "21.43.61.185", count: 37586 },
|
||||
{ name: "", ip: "224.77.182.60", count: 28664 },
|
||||
{ name: "", ip: "254.251.126.14", count: 28353 },
|
||||
{ name: "", ip: "45.236.46.22", count: 26034 },
|
||||
{ name: "", ip: "134.169.112.176", count: 23587 },
|
||||
{ name: "", ip: "59.190.76.175", count: 14595 },
|
||||
{ name: "", ip: "108.137.74.87", count: 11953 },
|
||||
{ name: "", ip: "127.219.136.176", count: 11715 },
|
||||
{ name: "", ip: "172.134.110.216", count: 5954 },
|
||||
{ name: "", ip: "243.77.12.200", count: 4703 }
|
||||
],
|
||||
blocked_queries: 38451
|
||||
};
|
||||
|
||||
it("transforms the API data correctly", async () => {
|
||||
const data = transformData(fakeData);
|
||||
|
||||
expect(data.blockedQueries).toEqual(fakeData.blocked_queries);
|
||||
expect(data.topClients).toEqual(fakeData.top_clients);
|
||||
});
|
||||
|
||||
it("creates an appropriately sized table", async () => {
|
||||
const rows = generateRows(key => key)(transformData(fakeData));
|
||||
|
||||
expect(rows).toHaveLength(fakeData.top_clients.length);
|
||||
});
|
||||
+3
-3
@@ -3,15 +3,15 @@
|
||||
* Network-wide ad blocking via your own hardware.
|
||||
*
|
||||
* Web Interface
|
||||
* TopBlocked component test
|
||||
* TopBlockedDomains component test
|
||||
*
|
||||
* This file is copyright under the latest version of the EUPL.
|
||||
* Please see LICENSE file for your rights under this license. */
|
||||
|
||||
import React from "react";
|
||||
import { generateRows, transformData } from "../TopBlocked";
|
||||
import { generateRows, transformData } from "../TopBlockedDomains";
|
||||
|
||||
const fakeData: ApiTopBlocked = {
|
||||
const fakeData: ApiTopBlockedDomains = {
|
||||
top_domains: [
|
||||
{ domain: "jaron.info", count: 54316 },
|
||||
{ domain: "candelario.info", count: 47470 },
|
||||
@@ -154,6 +154,7 @@
|
||||
|
||||
.boxcontainer .app-header {
|
||||
max-width: 1250px;
|
||||
border-bottom-color: #3c8dbc;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
|
||||
Vendored
+6
-1
@@ -177,7 +177,7 @@ interface ApiTopDomainItem {
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface ApiTopBlocked {
|
||||
interface ApiTopBlockedDomains {
|
||||
top_domains: Array<ApiTopDomainItem>;
|
||||
blocked_queries: number;
|
||||
}
|
||||
@@ -192,6 +192,11 @@ interface ApiTopClients {
|
||||
total_queries: number;
|
||||
}
|
||||
|
||||
interface ApiTopBlockedClients {
|
||||
top_clients: Array<ApiClient>;
|
||||
blocked_queries: number;
|
||||
}
|
||||
|
||||
interface ApiClient {
|
||||
name: string;
|
||||
ip: string;
|
||||
|
||||
@@ -104,7 +104,7 @@ describe("ApiClient", () => {
|
||||
|
||||
it("should call top blocked endpoint (top_domains?blocked=true)", async () => {
|
||||
config.fakeAPI = false;
|
||||
await expect(api.getTopBlocked()).resolves.toEqual(getData);
|
||||
await expect(api.getTopBlockedDomains()).resolves.toEqual(getData);
|
||||
expect(httpClient.get).toHaveBeenCalledWith(
|
||||
"stats/top_domains?blocked=true"
|
||||
);
|
||||
@@ -112,7 +112,7 @@ describe("ApiClient", () => {
|
||||
|
||||
it("should call top blocked endpoint (top_blocked)", async () => {
|
||||
config.fakeAPI = true;
|
||||
await expect(api.getTopBlocked()).resolves.toEqual(getData);
|
||||
await expect(api.getTopBlockedDomains()).resolves.toEqual(getData);
|
||||
expect(httpClient.get).toHaveBeenCalledWith("stats/top_blocked");
|
||||
});
|
||||
|
||||
@@ -121,6 +121,20 @@ describe("ApiClient", () => {
|
||||
expect(httpClient.get).toHaveBeenCalledWith("stats/top_clients");
|
||||
});
|
||||
|
||||
it("should call top blocked clients endpoint (top_clients?blocked=true)", async () => {
|
||||
config.fakeAPI = false;
|
||||
await expect(api.getTopBlockedClients()).resolves.toEqual(getData);
|
||||
expect(httpClient.get).toHaveBeenCalledWith(
|
||||
"stats/top_clients?blocked=true"
|
||||
);
|
||||
});
|
||||
|
||||
it("should call top blocked clients endpoint (top_blocked_clients)", async () => {
|
||||
config.fakeAPI = true;
|
||||
await expect(api.getTopBlockedClients()).resolves.toEqual(getData);
|
||||
expect(httpClient.get).toHaveBeenCalledWith("stats/top_blocked_clients");
|
||||
});
|
||||
|
||||
it("should call top history endpoint with params", async () => {
|
||||
const params = { test: "params" };
|
||||
await expect(api.getHistory(params)).resolves.toEqual(getData);
|
||||
@@ -180,7 +194,7 @@ describe("ApiClient", () => {
|
||||
|
||||
it("should call top blocked DB endpoint (top_domains?blocked=true) with time range", async () => {
|
||||
config.fakeAPI = false;
|
||||
await expect(api.getTopBlockedDb(range)).resolves.toEqual(getData);
|
||||
await expect(api.getTopBlockedDomainsDb(range)).resolves.toEqual(getData);
|
||||
expect(httpClient.get).toHaveBeenCalledWith(
|
||||
"stats/database/top_domains?blocked=true&" + rangeParams
|
||||
);
|
||||
@@ -188,7 +202,7 @@ describe("ApiClient", () => {
|
||||
|
||||
it("should call top blocked DB endpoint (top_blocked) with time range", async () => {
|
||||
config.fakeAPI = true;
|
||||
await expect(api.getTopBlockedDb(range)).resolves.toEqual(getData);
|
||||
await expect(api.getTopBlockedDomainsDb(range)).resolves.toEqual(getData);
|
||||
expect(httpClient.get).toHaveBeenCalledWith(
|
||||
"stats/database/top_blocked?" + rangeParams
|
||||
);
|
||||
@@ -200,6 +214,22 @@ describe("ApiClient", () => {
|
||||
"stats/database/top_clients?" + rangeParams
|
||||
);
|
||||
});
|
||||
|
||||
it("should call top blocked clients DB endpoint (top_clients?blocked=true) with time range", async () => {
|
||||
config.fakeAPI = false;
|
||||
await expect(api.getTopBlockedClientsDb(range)).resolves.toEqual(getData);
|
||||
expect(httpClient.get).toHaveBeenCalledWith(
|
||||
"stats/database/top_clients?blocked=true&" + rangeParams
|
||||
);
|
||||
});
|
||||
|
||||
it("should call top blocked clients DB endpoint (top_blocked_clients) with time range", async () => {
|
||||
config.fakeAPI = true;
|
||||
await expect(api.getTopBlockedClientsDb(range)).resolves.toEqual(getData);
|
||||
expect(httpClient.get).toHaveBeenCalledWith(
|
||||
"stats/database/top_blocked_clients?" + rangeParams
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dns calls", () => {
|
||||
|
||||
+30
-4
@@ -97,7 +97,7 @@ export class ApiClient {
|
||||
);
|
||||
};
|
||||
|
||||
getTopBlocked(): Promise<ApiTopBlocked> {
|
||||
getTopBlockedDomains = (): Promise<ApiTopBlockedDomains> => {
|
||||
// The API uses a GET parameter to differentiate top domains from top
|
||||
// blocked, but the fake API is not able to handle GET parameters right now.
|
||||
const url = this.http.config.fakeAPI
|
||||
@@ -105,9 +105,11 @@ export class ApiClient {
|
||||
: "stats/top_domains?blocked=true";
|
||||
|
||||
return this.http.get(url);
|
||||
}
|
||||
};
|
||||
|
||||
getTopBlockedDb(range: TimeRange): Promise<ApiTopBlocked> {
|
||||
getTopBlockedDomainsDb = (
|
||||
range: TimeRange
|
||||
): Promise<ApiTopBlockedDomains> => {
|
||||
// The API uses a GET parameter to differentiate top domains from top
|
||||
// blocked, but the fake API is not able to handle GET parameters right now.
|
||||
const url = this.http.config.fakeAPI
|
||||
@@ -115,7 +117,7 @@ export class ApiClient {
|
||||
: "stats/database/top_domains?blocked=true&";
|
||||
|
||||
return this.http.get(url + timeRangeToParams(range));
|
||||
}
|
||||
};
|
||||
|
||||
getTopClients = (): Promise<ApiTopClients> => {
|
||||
return this.http.get("stats/top_clients");
|
||||
@@ -127,6 +129,30 @@ export class ApiClient {
|
||||
);
|
||||
};
|
||||
|
||||
getTopBlockedClients = (): Promise<ApiTopBlockedClients> => {
|
||||
// The API uses a GET parameter to differentiate top clients from top
|
||||
// blocked clients, but the fake API is not able to handle GET parameters
|
||||
// right now.
|
||||
const url = this.http.config.fakeAPI
|
||||
? "stats/top_blocked_clients"
|
||||
: "stats/top_clients?blocked=true";
|
||||
|
||||
return this.http.get(url);
|
||||
};
|
||||
|
||||
getTopBlockedClientsDb = (
|
||||
range: TimeRange
|
||||
): Promise<ApiTopBlockedClients> => {
|
||||
// The API uses a GET parameter to differentiate top clients from top
|
||||
// blocked clients, but the fake API is not able to handle GET parameters
|
||||
// right now.
|
||||
const url = this.http.config.fakeAPI
|
||||
? "stats/database/top_blocked_clients?"
|
||||
: "stats/database/top_clients?blocked=true&";
|
||||
|
||||
return this.http.get(url + timeRangeToParams(range));
|
||||
};
|
||||
|
||||
getHistory = (params: any): Promise<ApiHistoryResponse> => {
|
||||
return this.http.get("stats/history?" + paramsToString(params));
|
||||
};
|
||||
|
||||
+13
-7
@@ -15,10 +15,11 @@ import ClientsGraph from "../components/dashboard/ClientsGraph";
|
||||
import QueryTypesChart from "../components/dashboard/QueryTypesChart";
|
||||
import UpstreamsChart from "../components/dashboard/UpstreamsChart";
|
||||
import TopDomains from "../components/dashboard/TopDomains";
|
||||
import TopBlocked from "../components/dashboard/TopBlocked";
|
||||
import TopBlockedDomains from "../components/dashboard/TopBlockedDomains";
|
||||
import TopClients from "../components/dashboard/TopClients";
|
||||
import api from "../util/api";
|
||||
import { TimeRangeSelectorContainer } from "../components/dashboard/TimeRangeSelector";
|
||||
import TopBlockedClients from "../components/dashboard/TopBlockedClients";
|
||||
|
||||
export default () => (
|
||||
<div className="animated fadeIn">
|
||||
@@ -43,23 +44,28 @@ export default () => (
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-md-6">
|
||||
<div className="col-lg-6 col-md-12">
|
||||
<QueryTypesChart />
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<div className="col-lg-6 col-md-12">
|
||||
<UpstreamsChart />
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-md-6 col-xl-4">
|
||||
<div className="col-lg-6 col-md-12">
|
||||
<TopDomains />
|
||||
</div>
|
||||
<div className="col-md-6 col-xl-4">
|
||||
<TopBlocked />
|
||||
<div className="col-lg-6 col-md-12">
|
||||
<TopBlockedDomains />
|
||||
</div>
|
||||
<div className="col-md-6 col-xl-4">
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-lg-6 col-md-12">
|
||||
<TopClients />
|
||||
</div>
|
||||
<div className="col-lg-6 col-md-12">
|
||||
<TopBlockedClients />
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user