+client: "Drill down" to activity reports

Close #1625

Squashed commit of the following:

commit a01f12c4e5831c43dbe3ae8a80f4db12077dbb2a
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Mon Jul 13 15:50:15 2020 +0300

    minor

commit b8ceb17a3b12e47de81af85fa30c2961a4a42fab
Merge: 702c55ed fecf5494
Author: Andrey Meshkov <am@adguard.com>
Date:   Mon Jul 13 15:32:44 2020 +0300

    Merge branch 'feature/1625' of ssh://bit.adguard.com:7999/dns/adguard-home into feature/1625

commit 702c55edc1ba2ab330eda8189498dfff33c92f5f
Author: Andrey Meshkov <am@adguard.com>
Date:   Mon Jul 13 15:32:41 2020 +0300

    fix makefile when there's no gopath

commit fecf5494b8c1719cb70044f336fe99c341802d25
Merge: d4c811f9 8a417604
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Mon Jul 13 15:30:21 2020 +0300

    Merge branch 'master' into feature/1625

commit d4c811f9630dee448012434e2f50f34ab8b8b899
Merge: b0a037da a33164bf
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Mon Jul 13 12:35:16 2020 +0300

    Merge branch 'master' into feature/1625

commit b0a037daf48913fd8a4cda16d520835630072520
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Mon Jul 13 12:34:42 2020 +0300

    Simplify sync logs action creators

commit eeeb620ae100a554f59783fc2a14fad525ce1a82
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Mon Jul 13 11:17:08 2020 +0300

    Review changes

commit 4cbc59eec5c794df18d6cb9b33f39091ce7cfde9
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Fri Jul 10 15:23:37 2020 +0300

    Update tracker tooltip class

commit 0a705301d4726af1c8f7f7a5776b11d338ab1d54
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Fri Jul 10 13:46:10 2020 +0300

    Replace depricated addListener

commit 2ac0843239853da1725d2e038b5e4cbaef253732
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Fri Jul 10 13:39:45 2020 +0300

    Validate response_status url param

commit 2178039ebbd0cbe2c0048cb5ab7ad7c7e7571bd1
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Fri Jul 10 12:58:18 2020 +0300

    Fix setting empty search value, use strict search on drill down, extract refreshFilteredLogs action

commit 4b11c6a34049bd133077bad035d267f87cdec141
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Thu Jul 9 19:41:48 2020 +0300

    Normalize input search

commit 3fded3575b21bdd017723f5e487c268074599e4f
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Thu Jul 9 18:20:05 2020 +0300

    Optimize search

commit 9073e032e4aadcdef9d826f16a10c300ee46b30e
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Thu Jul 9 14:28:41 2020 +0300

    Update url string params

commit a18cffc8bfac83103fb78ffae2f786f89aea8ba1
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Thu Jul 9 12:55:50 2020 +0300

    Fix reset search

commit 33f769aed56369aacedd29ffd52b527b527d4a59
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Wed Jul 8 19:13:21 2020 +0300

    WIP: Add permlinks

commit 4422641cf5cff06c8485ea23d58e5d42f7cca5cd
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Wed Jul 8 14:42:28 2020 +0300

    Refactor Counters, add response_status links to query log

commit e8bb0b70ca55f31ef3fcdda13dcaad6f5d8479b5
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Tue Jul 7 19:33:04 2020 +0300

    Delete unnecessary file

commit b20816e9dad79866e3ec04d3093c972967b3b226
Merge: 6281084e d2c3af5c
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Tue Jul 7 19:30:44 2020 +0300

    Resolve conflict

commit d2c3af5cf227d76f876d6d94ca016d4b242b2515
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Tue Jul 7 17:14:51 2020 +0300

    + client: Add git hooks

... and 5 more commits
This commit is contained in:
Artem Baskal 2020-07-13 16:06:56 +03:00
parent 8a417604a9
commit da4a1ec23d
30 changed files with 591 additions and 331 deletions

View File

@ -92,7 +92,7 @@ endif
all: build all: build
build: dependencies client build: dependencies client
go generate ./... PATH=$(GOPATH)/bin:$(PATH) go generate ./...
CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=$(VERSION) -X main.channel=$(CHANNEL) -X main.goarm=$(GOARM)" CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=$(VERSION) -X main.channel=$(CHANNEL) -X main.goarm=$(GOARM)"
PATH=$(GOPATH)/bin:$(PATH) packr clean PATH=$(GOPATH)/bin:$(PATH) packr clean

67
client/package-lock.json generated vendored
View File

@ -1909,6 +1909,11 @@
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true "dev": true
}, },
"kind-of": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz",
"integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA=="
},
"micromatch": { "micromatch": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz",
@ -3097,6 +3102,11 @@
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true "dev": true
}, },
"minimist": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
"integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ="
},
"supports-color": { "supports-color": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz",
@ -4818,8 +4828,7 @@
"decode-uri-component": { "decode-uri-component": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
"integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU="
"dev": true
}, },
"deep-equal": { "deep-equal": {
"version": "1.1.1", "version": "1.1.1",
@ -11044,6 +11053,24 @@
"prepend-http": "^1.0.0", "prepend-http": "^1.0.0",
"query-string": "^4.1.0", "query-string": "^4.1.0",
"sort-keys": "^1.0.0" "sort-keys": "^1.0.0"
},
"dependencies": {
"query-string": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz",
"integrity": "sha1-u7aTucqRXCMlFbIosaArYJBD2+s=",
"dev": true,
"requires": {
"object-assign": "^4.1.0",
"strict-uri-encode": "^1.0.0"
}
},
"strict-uri-encode": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz",
"integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=",
"dev": true
}
} }
}, },
"npm-run-path": { "npm-run-path": {
@ -12138,13 +12165,13 @@
"dev": true "dev": true
}, },
"query-string": { "query-string": {
"version": "4.3.4", "version": "6.13.1",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz", "resolved": "https://registry.npmjs.org/query-string/-/query-string-6.13.1.tgz",
"integrity": "sha1-u7aTucqRXCMlFbIosaArYJBD2+s=", "integrity": "sha512-RfoButmcK+yCta1+FuU8REvisx1oEzhMKwhLUNcepQTPGcNMp1sIqjnfCtfnvGSQZQEhaBHvccujtWoUV3TTbA==",
"dev": true,
"requires": { "requires": {
"object-assign": "^4.1.0", "decode-uri-component": "^0.2.0",
"strict-uri-encode": "^1.0.0" "split-on-first": "^1.0.0",
"strict-uri-encode": "^2.0.0"
} }
}, },
"querystring": { "querystring": {
@ -13455,6 +13482,18 @@
"is-data-descriptor": "^1.0.0", "is-data-descriptor": "^1.0.0",
"kind-of": "^6.0.2" "kind-of": "^6.0.2"
} }
},
"isobject": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
"integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=",
"dev": true
},
"kind-of": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz",
"integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==",
"dev": true
} }
} }
}, },
@ -13696,6 +13735,11 @@
"integrity": "sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg==", "integrity": "sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg==",
"dev": true "dev": true
}, },
"split-on-first": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
"integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="
},
"split-string": { "split-string": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
@ -13833,10 +13877,9 @@
"dev": true "dev": true
}, },
"strict-uri-encode": { "strict-uri-encode": {
"version": "1.1.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
"integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=", "integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY="
"dev": true
}, },
"string-length": { "string-length": {
"version": "4.0.1", "version": "4.0.1",

1
client/package.json vendored
View File

@ -22,6 +22,7 @@
"lodash": "^4.17.15", "lodash": "^4.17.15",
"nanoid": "^3.1.9", "nanoid": "^3.1.9",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"query-string": "^6.13.1",
"react": "^16.13.1", "react": "^16.13.1",
"react-click-outside": "^3.0.1", "react-click-outside": "^3.0.1",
"react-dom": "^16.13.1", "react-dom": "^16.13.1",

View File

@ -562,5 +562,6 @@
"filter_category_security_desc": "Lists that specialize on blocking malware, phishing or scam domains", "filter_category_security_desc": "Lists that specialize on blocking malware, phishing or scam domains",
"filter_category_regional_desc": "Lists that focus on regional ads and tracking servers", "filter_category_regional_desc": "Lists that focus on regional ads and tracking servers",
"filter_category_other_desc": "Other blocklists", "filter_category_other_desc": "Other blocklists",
"original_response": "Original response" "original_response": "Original response",
"click_to_view_queries": "Click to view queries"
} }

View File

@ -2,12 +2,19 @@ import { createAction } from 'redux-actions';
import apiClient from '../api/Api'; import apiClient from '../api/Api';
import { normalizeLogs, getParamsForClientsSearch, addClientInfo } from '../helpers/helpers'; import { normalizeLogs, getParamsForClientsSearch, addClientInfo } from '../helpers/helpers';
import { TABLE_DEFAULT_PAGE_SIZE, TABLE_FIRST_PAGE } from '../helpers/constants'; import {
DEFAULT_LOGS_FILTER,
TABLE_DEFAULT_PAGE_SIZE,
TABLE_FIRST_PAGE,
} from '../helpers/constants';
import { addErrorToast, addSuccessToast } from './toasts'; import { addErrorToast, addSuccessToast } from './toasts';
const getLogsWithParams = async (config) => { const getLogsWithParams = async (config) => {
const { older_than, filter, ...values } = config; const { older_than, filter, ...values } = config;
const rawLogs = await apiClient.getQueryLog({ ...filter, older_than }); const rawLogs = await apiClient.getQueryLog({
...filter,
older_than,
});
const { data, oldest } = rawLogs; const { data, oldest } = rawLogs;
let logs = normalizeLogs(data); let logs = normalizeLogs(data);
const clientsParams = getParamsForClientsSearch(logs, 'client'); const clientsParams = getParamsForClientsSearch(logs, 'client');
@ -18,7 +25,11 @@ const getLogsWithParams = async (config) => {
} }
return { return {
logs, oldest, older_than, filter, ...values, logs,
oldest,
older_than,
filter,
...values,
}; };
}; };
@ -38,7 +49,10 @@ const checkFilteredLogs = async (data, filter, dispatch, total) => {
dispatch(getAdditionalLogsRequest()); dispatch(getAdditionalLogsRequest());
try { try {
const additionalLogs = await getLogsWithParams({ older_than: oldest, filter }); const additionalLogs = await getLogsWithParams({
older_than: oldest,
filter,
});
if (additionalLogs.oldest.length > 0) { if (additionalLogs.oldest.length > 0) {
return await checkFilteredLogs(additionalLogs, filter, dispatch, { return await checkFilteredLogs(additionalLogs, filter, dispatch, {
logs: [...totalData.logs, ...additionalLogs.logs], logs: [...totalData.logs, ...additionalLogs.logs],
@ -69,13 +83,19 @@ export const getLogs = (config) => async (dispatch, getState) => {
dispatch(getLogsRequest()); dispatch(getLogsRequest());
try { try {
const { isFiltered, filter, page } = getState().queryLogs; const { isFiltered, filter, page } = getState().queryLogs;
const data = await getLogsWithParams({ ...config, filter }); const data = await getLogsWithParams({
...config,
filter,
});
if (isFiltered) { if (isFiltered) {
const additionalData = await checkFilteredLogs(data, filter, dispatch); const additionalData = await checkFilteredLogs(data, filter, dispatch);
const updatedData = additionalData.logs ? { ...data, ...additionalData } : data; const updatedData = additionalData.logs ? { ...data, ...additionalData } : data;
dispatch(getLogsSuccess(updatedData)); dispatch(getLogsSuccess(updatedData));
dispatch(setLogsPagination({ page, pageSize: TABLE_DEFAULT_PAGE_SIZE })); dispatch(setLogsPagination({
page,
pageSize: TABLE_DEFAULT_PAGE_SIZE,
}));
} else { } else {
dispatch(getLogsSuccess(data)); dispatch(getLogsSuccess(data));
} }
@ -86,24 +106,48 @@ export const getLogs = (config) => async (dispatch, getState) => {
}; };
export const setLogsFilterRequest = createAction('SET_LOGS_FILTER_REQUEST'); export const setLogsFilterRequest = createAction('SET_LOGS_FILTER_REQUEST');
export const setLogsFilterFailure = createAction('SET_LOGS_FILTER_FAILURE');
export const setLogsFilterSuccess = createAction('SET_LOGS_FILTER_SUCCESS');
export const setLogsFilter = (filter) => async (dispatch) => { /**
dispatch(setLogsFilterRequest()); *
* @param filter
* @param {string} filter.search
* @param {string} filter.response_status query field of RESPONSE_FILTER object
* @returns function
*/
export const setLogsFilter = (filter) => setLogsFilterRequest(filter);
export const setFilteredLogsRequest = createAction('SET_FILTERED_LOGS_REQUEST');
export const setFilteredLogsFailure = createAction('SET_FILTERED_LOGS_FAILURE');
export const setFilteredLogsSuccess = createAction('SET_FILTERED_LOGS_SUCCESS');
export const setFilteredLogs = (filter) => async (dispatch) => {
dispatch(setFilteredLogsRequest());
try { try {
const data = await getLogsWithParams({ older_than: '', filter }); const data = await getLogsWithParams({
older_than: '',
filter,
});
const additionalData = await checkFilteredLogs(data, filter, dispatch); const additionalData = await checkFilteredLogs(data, filter, dispatch);
const updatedData = additionalData.logs ? { ...data, ...additionalData } : data; const updatedData = additionalData.logs ? { ...data, ...additionalData } : data;
dispatch(setLogsFilterSuccess({ ...updatedData, filter })); dispatch(setFilteredLogsSuccess({
...updatedData,
filter,
}));
dispatch(setLogsPage(TABLE_FIRST_PAGE)); dispatch(setLogsPage(TABLE_FIRST_PAGE));
} catch (error) { } catch (error) {
dispatch(addErrorToast({ error })); dispatch(addErrorToast({ error }));
dispatch(setLogsFilterFailure(error)); dispatch(setFilteredLogsFailure(error));
} }
}; };
export const resetFilteredLogs = () => setFilteredLogs(DEFAULT_LOGS_FILTER);
export const refreshFilteredLogs = () => async (dispatch, getState) => {
const { filter } = getState().queryLogs;
await dispatch(setFilteredLogs(filter));
};
export const clearLogsRequest = createAction('CLEAR_LOGS_REQUEST'); export const clearLogsRequest = createAction('CLEAR_LOGS_REQUEST');
export const clearLogsFailure = createAction('CLEAR_LOGS_FAILURE'); export const clearLogsFailure = createAction('CLEAR_LOGS_FAILURE');
export const clearLogsSuccess = createAction('CLEAR_LOGS_SUCCESS'); export const clearLogsSuccess = createAction('CLEAR_LOGS_SUCCESS');

View File

@ -36,7 +36,7 @@ import i18n from '../../i18n';
import Loading from '../ui/Loading'; import Loading from '../ui/Loading';
import { FILTERS_URLS, MENU_URLS, SETTINGS_URLS } from '../../helpers/constants'; import { FILTERS_URLS, MENU_URLS, SETTINGS_URLS } from '../../helpers/constants';
import Services from '../Filters/Services'; import Services from '../Filters/Services';
import { setHtmlLangAttr } from '../../helpers/helpers'; import { getLogsUrlParams, setHtmlLangAttr } from '../../helpers/helpers';
class App extends Component { class App extends Component {
componentDidMount() { componentDidMount() {
@ -111,7 +111,9 @@ class App extends Component {
{!dashboard.processing && dashboard.isCoreRunning && ( {!dashboard.processing && dashboard.isCoreRunning && (
<> <>
<Route path={MENU_URLS.root} exact component={Dashboard} /> <Route path={MENU_URLS.root} exact component={Dashboard} />
<Route path={MENU_URLS.logs} component={Logs} /> <Route
path={[`${MENU_URLS.logs}${getLogsUrlParams(':search?', ':response_status?')}`, MENU_URLS.logs]}
component={Logs} />
<Route path={MENU_URLS.guide} component={SetupGuide} /> <Route path={MENU_URLS.guide} component={SetupGuide} />
<Route path={SETTINGS_URLS.settings} component={Settings} /> <Route path={SETTINGS_URLS.settings} component={Settings} />
<Route path={SETTINGS_URLS.dns} component={Dns} /> <Route path={SETTINGS_URLS.dns} component={Dns} />

View File

@ -14,7 +14,11 @@ const CountCell = (totalBlocked) => function cell(row) {
const { value } = row; const { value } = row;
const percent = getPercent(totalBlocked, value); const percent = getPercent(totalBlocked, value);
return <Cell value={value} percent={percent} color={STATUS_COLORS.red} />; return <Cell value={value}
percent={percent}
color={STATUS_COLORS.red}
search={row.original.domain}
/>;
}; };
const BlockedDomains = ({ const BlockedDomains = ({

View File

@ -25,7 +25,7 @@ const countCell = (dnsQueries) => function cell(row) {
const percent = getPercent(dnsQueries, value); const percent = getPercent(dnsQueries, value);
const percentColor = getClientsPercentColor(percent); const percentColor = getClientsPercentColor(percent);
return <Cell value={value} percent={percent} color={percentColor} />; return <Cell value={value} percent={percent} color={percentColor} search={row.original.ip} />;
}; };
const renderBlockingButton = (ipMatchListStatus, ip, handleClick, processing) => { const renderBlockingButton = (ipMatchListStatus, ip, handleClick, processing) => {

View File

@ -1,31 +1,80 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import propTypes from 'prop-types';
import { Trans, withTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import round from 'lodash/round'; import round from 'lodash/round';
import { shallowEqual, useSelector } from 'react-redux';
import Card from '../ui/Card'; import Card from '../ui/Card';
import Tooltip from '../ui/Tooltip'; import IconTooltip from '../ui/IconTooltip';
import { formatNumber } from '../../helpers/helpers'; import { formatNumber } from '../../helpers/helpers';
import LogsSearchLink from '../ui/LogsSearchLink';
import { RESPONSE_FILTER } from '../../helpers/constants';
const tooltipType = 'tooltip-custom--narrow'; const Row = ({
label, count, response_status, tooltipTitle, translationComponents,
}) => {
const content = response_status
? <LogsSearchLink response_status={response_status}>{formatNumber(count)}</LogsSearchLink>
: count;
const Counters = (props) => { return <tr key={label}>
<td>
<Trans components={translationComponents}>{label}</Trans>
<IconTooltip text={tooltipTitle} type="tooltip-custom--narrow" />
</td>
<td className="text-right"><strong>{content}</strong></td>
</tr>;
};
const Counters = ({ refreshButton, subtitle }) => {
const { const {
t,
interval, interval,
refreshButton, numDnsQueries,
subtitle, numBlockedFiltering,
dnsQueries, numReplacedSafebrowsing,
blockedFiltering, numReplacedParental,
replacedSafebrowsing, numReplacedSafesearch,
replacedParental,
replacedSafesearch,
avgProcessingTime, avgProcessingTime,
} = props; } = useSelector((state) => state.stats, shallowEqual);
const { t } = useTranslation();
const tooltipTitle = interval === 1 const rows = [
? t('number_of_dns_query_24_hours') {
: t('number_of_dns_query_days', { count: interval }); label: 'dns_query',
count: numDnsQueries,
tooltipTitle: interval === 1 ? 'number_of_dns_query_24_hours' : t('number_of_dns_query_days', { count: interval }),
response_status: RESPONSE_FILTER.ALL.query,
},
{
label: 'blocked_by',
count: numBlockedFiltering,
tooltipTitle: 'number_of_dns_query_blocked_24_hours',
response_status: RESPONSE_FILTER.BLOCKED.query,
translationComponents: [<a href="#filters" key="0">link</a>],
},
{
label: 'stats_malware_phishing',
count: numReplacedSafebrowsing,
tooltipTitle: 'number_of_dns_query_blocked_24_hours_by_sec',
response_status: RESPONSE_FILTER.BLOCKED_THREATS.query,
},
{
label: 'stats_adult',
count: numReplacedParental,
tooltipTitle: 'number_of_dns_query_blocked_24_hours_adult',
response_status: RESPONSE_FILTER.BLOCKED_ADULT_WEBSITES.query,
},
{
label: 'enforced_save_search',
count: numReplacedSafesearch,
tooltipTitle: 'number_of_dns_query_to_safe_search',
response_status: RESPONSE_FILTER.SAFE_SEARCH.query,
},
{
label: 'average_processing_time',
count: avgProcessingTime ? `${round(avgProcessingTime)} ms` : 0,
tooltipTitle: 'average_processing_time_hint',
},
];
return ( return (
<Card <Card
@ -35,104 +84,23 @@ const Counters = (props) => {
refresh={refreshButton} refresh={refreshButton}
> >
<table className="table card-table"> <table className="table card-table">
<tbody> <tbody>{rows.map(Row)}</tbody>
<tr>
<td>
<Trans>dns_query</Trans>
<Tooltip text={tooltipTitle} type={tooltipType} />
</td>
<td className="text-right">
<span className="text-muted">
{formatNumber(dnsQueries)}
</span>
</td>
</tr>
<tr>
<td>
<Trans components={[<a href="#filters" key="0">link</a>]}>
blocked_by
</Trans>
<Tooltip
text={t('number_of_dns_query_blocked_24_hours')}
type={tooltipType}
/>
</td>
<td className="text-right">
<span className="text-muted">
{formatNumber(blockedFiltering)}
</span>
</td>
</tr>
<tr>
<td>
<Trans>stats_malware_phishing</Trans>
<Tooltip
text={t('number_of_dns_query_blocked_24_hours_by_sec')}
type={tooltipType}
/>
</td>
<td className="text-right">
<span className="text-muted">
{formatNumber(replacedSafebrowsing)}
</span>
</td>
</tr>
<tr>
<td>
<Trans>stats_adult</Trans>
<Tooltip
text={t('number_of_dns_query_blocked_24_hours_adult')}
type={tooltipType}
/>
</td>
<td className="text-right">
<span className="text-muted">
{formatNumber(replacedParental)}
</span>
</td>
</tr>
<tr>
<td>
<Trans>enforced_save_search</Trans>
<Tooltip
text={t('number_of_dns_query_to_safe_search')}
type={tooltipType}
/>
</td>
<td className="text-right">
<span className="text-muted">
{formatNumber(replacedSafesearch)}
</span>
</td>
</tr>
<tr>
<td>
<Trans>average_processing_time</Trans>
<Tooltip text={t('average_processing_time_hint')} type={tooltipType} />
</td>
<td className="text-right">
<span className="text-muted">
{avgProcessingTime ? `${round(avgProcessingTime)} ms` : 0}
</span>
</td>
</tr>
</tbody>
</table> </table>
</Card> </Card>
); );
}; };
Counters.propTypes = { Row.propTypes = {
dnsQueries: PropTypes.number.isRequired, label: propTypes.string.isRequired,
blockedFiltering: PropTypes.number.isRequired, count: propTypes.string.isRequired,
replacedSafebrowsing: PropTypes.number.isRequired, response_status: propTypes.string,
replacedParental: PropTypes.number.isRequired, tooltipTitle: propTypes.string.isRequired,
replacedSafesearch: PropTypes.number.isRequired, translationComponents: propTypes.arrayOf(propTypes.element),
avgProcessingTime: PropTypes.number.isRequired,
refreshButton: PropTypes.node.isRequired,
subtitle: PropTypes.string.isRequired,
interval: PropTypes.number.isRequired,
t: PropTypes.func.isRequired,
}; };
export default withTranslation()(Counters); Counters.propTypes = {
refreshButton: propTypes.node.isRequired,
subtitle: propTypes.string.isRequired,
};
export default Counters;

View File

@ -13,7 +13,8 @@ import { getPercent } from '../../helpers/helpers';
const getQueriedPercentColor = (percent) => { const getQueriedPercentColor = (percent) => {
if (percent > 10) { if (percent > 10) {
return STATUS_COLORS.red; return STATUS_COLORS.red;
} if (percent > 5) { }
if (percent > 5) {
return STATUS_COLORS.yellow; return STATUS_COLORS.yellow;
} }
return STATUS_COLORS.green; return STATUS_COLORS.green;
@ -24,7 +25,8 @@ const countCell = (dnsQueries) => function cell(row) {
const percent = getPercent(dnsQueries, value); const percent = getPercent(dnsQueries, value);
const percentColor = getQueriedPercentColor(percent); const percentColor = getQueriedPercentColor(percent);
return <Cell value={value} percent={percent} color={percentColor} />; return <Cell value={value} percent={percent} color={percentColor}
search={row.original.domain} />;
}; };
const QueriedDomains = ({ const QueriedDomains = ({

View File

@ -111,13 +111,6 @@ class Dashboard extends Component {
<div className="col-lg-6"> <div className="col-lg-6">
<Counters <Counters
subtitle={subtitle} subtitle={subtitle}
interval={stats.interval}
dnsQueries={stats.numDnsQueries}
blockedFiltering={stats.numBlockedFiltering}
replacedSafebrowsing={stats.numReplacedSafebrowsing}
replacedParental={stats.numReplacedParental}
replacedSafesearch={stats.numReplacedSafesearch}
avgProcessingTime={stats.avgProcessingTime}
refreshButton={refreshButton} refreshButton={refreshButton}
/> />
</div> </div>

View File

@ -1,16 +1,19 @@
import React, { Component, Fragment } from 'react'; import React, { Component } from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import enhanceWithClickOutside from 'react-click-outside'; import enhanceWithClickOutside from 'react-click-outside';
import classnames from 'classnames'; import classnames from 'classnames';
import { Trans, withTranslation } from 'react-i18next'; import { Trans, withTranslation } from 'react-i18next';
import { SETTINGS_URLS, FILTERS_URLS, MENU_URLS } from '../../helpers/constants'; import { SETTINGS_URLS, FILTERS_URLS, MENU_URLS } from '../../helpers/constants';
import Dropdown from '../ui/Dropdown'; import Dropdown from '../ui/Dropdown';
const MENU_ITEMS = [ const MENU_ITEMS = [
{ {
route: MENU_URLS.root, exact: true, icon: 'dashboard', text: 'dashboard', order: 0, route: MENU_URLS.root,
exact: true,
icon: 'dashboard',
text: 'dashboard',
order: 0,
}, },
// Settings dropdown should have visual order 1 // Settings dropdown should have visual order 1
@ -18,27 +21,63 @@ const MENU_ITEMS = [
// Filters dropdown should have visual order 2 // Filters dropdown should have visual order 2
{ {
route: MENU_URLS.logs, icon: 'log', text: 'query_log', order: 3, route: MENU_URLS.logs,
icon: 'log',
text: 'query_log',
order: 3,
}, },
{ {
route: MENU_URLS.guide, icon: 'setup', text: 'setup_guide', order: 4, route: MENU_URLS.guide,
icon: 'setup',
text: 'setup_guide',
order: 4,
}, },
]; ];
const SETTINGS_ITEMS = [ const SETTINGS_ITEMS = [
{ route: SETTINGS_URLS.settings, text: 'general_settings' }, {
{ route: SETTINGS_URLS.dns, text: 'dns_settings' }, route: SETTINGS_URLS.settings,
{ route: SETTINGS_URLS.encryption, text: 'encryption_settings' }, text: 'general_settings',
{ route: SETTINGS_URLS.clients, text: 'client_settings' }, },
{ route: SETTINGS_URLS.dhcp, text: 'dhcp_settings' }, {
route: SETTINGS_URLS.dns,
text: 'dns_settings',
},
{
route: SETTINGS_URLS.encryption,
text: 'encryption_settings',
},
{
route: SETTINGS_URLS.clients,
text: 'client_settings',
},
{
route: SETTINGS_URLS.dhcp,
text: 'dhcp_settings',
},
]; ];
const FILTERS_ITEMS = [ const FILTERS_ITEMS = [
{ route: FILTERS_URLS.dns_blocklists, text: 'dns_blocklists' }, {
{ route: FILTERS_URLS.dns_allowlists, text: 'dns_allowlists' }, route: FILTERS_URLS.dns_blocklists,
{ route: FILTERS_URLS.dns_rewrites, text: 'dns_rewrites' }, text: 'dns_blocklists',
{ route: FILTERS_URLS.blocked_services, text: 'blocked_services' }, },
{ route: FILTERS_URLS.custom_rules, text: 'custom_filtering_rules' }, {
route: FILTERS_URLS.dns_allowlists,
text: 'dns_allowlists',
},
{
route: FILTERS_URLS.dns_rewrites,
text: 'dns_rewrites',
},
{
route: FILTERS_URLS.blocked_services,
text: 'blocked_services',
},
{
route: FILTERS_URLS.custom_rules,
text: 'custom_filtering_rules',
},
]; ];
class Menu extends Component { class Menu extends Component {
@ -52,7 +91,8 @@ class Menu extends Component {
getActiveClassForDropdown = (URLS) => { getActiveClassForDropdown = (URLS) => {
const { pathname } = this.props.location; const { pathname } = this.props.location;
const isActivePage = Object.values(URLS).some((item) => item === pathname); const isActivePage = Object.values(URLS)
.some((item) => item === pathname);
return isActivePage ? 'active' : ''; return isActivePage ? 'active' : '';
}; };
@ -79,18 +119,18 @@ class Menu extends Component {
getDropdown = ({ getDropdown = ({
label, order, URLS, icon, ITEMS, label, order, URLS, icon, ITEMS,
}) => ( }) => (
<Dropdown <Dropdown
label={this.props.t(label)} label={this.props.t(label)}
baseClassName={`dropdown nav-item order-${order}`} baseClassName='dropdown'
controlClassName={`nav-link ${this.getActiveClassForDropdown(URLS)}`} controlClassName={`nav-link ${this.getActiveClassForDropdown(URLS)}`}
icon={icon}> icon={icon}>
{ITEMS.map((item) => ( {ITEMS.map((item) => (
this.getNavLink({ this.getNavLink({
...item, ...item,
order, order,
className: 'dropdown-item', className: 'dropdown-item',
})))} })))}
</Dropdown> </Dropdown>
); );
render() { render() {
@ -99,7 +139,7 @@ class Menu extends Component {
'mobile-menu--active': this.props.isMenuOpen, 'mobile-menu--active': this.props.isMenuOpen,
}); });
return ( return (
<Fragment> <>
<div className={menuClass}> <div className={menuClass}>
<ul className="nav nav-tabs border-0 flex-column flex-lg-row flex-nowrap"> <ul className="nav nav-tabs border-0 flex-column flex-lg-row flex-nowrap">
{MENU_ITEMS.map((item) => ( {MENU_ITEMS.map((item) => (
@ -108,26 +148,33 @@ class Menu extends Component {
key={item.text} key={item.text}
onClick={this.closeMenu} onClick={this.closeMenu}
> >
{this.getNavLink({ ...item, className: 'nav-link' })} {this.getNavLink({
...item,
className: 'nav-link',
})}
</li> </li>
))} ))}
{this.getDropdown({ <li className="nav-item order-1">
order: 1, {this.getDropdown({
label: 'settings', order: 1,
icon: 'settings', label: 'settings',
URLS: SETTINGS_URLS, icon: 'settings',
ITEMS: SETTINGS_ITEMS, URLS: SETTINGS_URLS,
})} ITEMS: SETTINGS_ITEMS,
{this.getDropdown({ })}
order: 2, </li>
label: 'filters', <li className="nav-item order-2">
icon: 'filters', {this.getDropdown({
URLS: FILTERS_URLS, order: 2,
ITEMS: FILTERS_ITEMS, label: 'filters',
})} icon: 'filters',
URLS: FILTERS_URLS,
ITEMS: FILTERS_ITEMS,
})}
</li>
</ul> </ul>
</div> </div>
</Fragment> </>
); );
} }
} }

View File

@ -21,13 +21,13 @@ const getDomainCell = (props) => {
const hasTracker = !!tracker; const hasTracker = !!tracker;
const lockIconClass = classNames('icons', 'icon--small', 'd-none', 'd-sm-block', 'cursor--pointer', { const lockIconClass = classNames('icons icon--small d-none d-sm-block cursor--pointer', {
'icon--active': answer_dnssec, 'icon--active': answer_dnssec,
'icon--disabled': !answer_dnssec, 'icon--disabled': !answer_dnssec,
'my-3': isDetailed, 'my-3': isDetailed,
}); });
const privacyIconClass = classNames('icons', 'mx-2', 'icon--small', 'd-none', 'd-sm-block', 'cursor--pointer', { const privacyIconClass = classNames('icons mx-2 icon--small d-none d-sm-block cursor--pointer', {
'icon--active': hasTracker, 'icon--active': hasTracker,
'icon--disabled': !hasTracker, 'icon--disabled': !hasTracker,
'my-3': isDetailed, 'my-3': isDetailed,
@ -56,7 +56,7 @@ const getDomainCell = (props) => {
const renderGrid = (content, idx) => { const renderGrid = (content, idx) => {
const preparedContent = typeof content === 'string' ? t(content) : content; const preparedContent = typeof content === 'string' ? t(content) : content;
const className = classNames('text-truncate key-colon o-hidden', { const className = classNames('text-truncate o-hidden', {
'overflow-break': preparedContent.length > 100, 'overflow-break': preparedContent.length > 100,
}); });
return <div key={idx} className={className}>{preparedContent}</div>; return <div key={idx} className={className}>{preparedContent}</div>;

View File

@ -2,17 +2,20 @@ import React, { useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Field, reduxForm } from 'redux-form'; import { Field, reduxForm } from 'redux-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import debounce from 'lodash/debounce'; import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux'; import { useHistory } from 'react-router-dom';
import classNames from 'classnames'; import classNames from 'classnames';
import { import {
DEBOUNCE_FILTER_TIMEOUT, DEBOUNCE_FILTER_TIMEOUT,
DEFAULT_LOGS_FILTER, DEFAULT_LOGS_FILTER,
FORM_NAME, FORM_NAME,
RESPONSE_FILTER, RESPONSE_FILTER,
RESPONSE_FILTER_QUERIES,
} from '../../../helpers/constants'; } from '../../../helpers/constants';
import Tooltip from '../../ui/Tooltip'; import IconTooltip from '../../ui/IconTooltip';
import { setLogsFilter } from '../../../actions/queryLogs'; import { setLogsFilter } from '../../../actions/queryLogs';
import useDebounce from '../../../helpers/useDebounce';
import { createOnBlurHandler, getLogsUrlParams } from '../../../helpers/helpers';
const renderFilterField = ({ const renderFilterField = ({
input, input,
@ -25,34 +28,43 @@ const renderFilterField = ({
tooltip, tooltip,
meta: { touched, error }, meta: { touched, error },
onClearInputClick, onClearInputClick,
}) => <> onKeyDown,
<div className="input-group-search input-group-search__icon--magnifier"> normalizeOnBlur,
<svg className="icons icon--small icon--gray"> }) => {
<use xlinkHref="#magnifier" /> const onBlur = (event) => createOnBlurHandler(event, input, normalizeOnBlur);
</svg>
</div> return <>
<input <div className="input-group-search input-group-search__icon--magnifier">
{...input} <svg className="icons icon--small icon--gray">
id={id} <use xlinkHref="#magnifier" />
placeholder={placeholder} </svg>
type={type} </div>
className={className} <input
disabled={disabled} {...input}
autoComplete={autoComplete} id={id}
aria-label={placeholder} /> placeholder={placeholder}
<div type={type}
className={classNames('input-group-search input-group-search__icon--cross', { invisible: input.value.length < 1 })}> className={className}
<svg className="icons icon--smallest icon--gray" onClick={onClearInputClick}> disabled={disabled}
<use xlinkHref="#cross" /> autoComplete={autoComplete}
</svg> aria-label={placeholder}
</div> onKeyDown={onKeyDown}
<span className="input-group-search input-group-search__icon--tooltip"> onBlur={onBlur}
<Tooltip text={tooltip} type='tooltip-custom--logs' /> />
<div
className={classNames('input-group-search input-group-search__icon--cross', { invisible: input.value.length < 1 })}>
<svg className="icons icon--smallest icon--gray" onClick={onClearInputClick}>
<use xlinkHref="#cross" />
</svg>
</div>
<span className="input-group-search input-group-search__icon--tooltip">
<IconTooltip text={tooltip} type='tooltip-custom--logs' />
</span> </span>
{!disabled {!disabled
&& touched && touched
&& (error && <span className="form__message form__message--error">{error}</span>)} && (error && <span className="form__message form__message--error">{error}</span>)}
</>; </>;
};
renderFilterField.propTypes = { renderFilterField.propTypes = {
input: PropTypes.object.isRequired, input: PropTypes.object.isRequired,
@ -64,65 +76,91 @@ renderFilterField.propTypes = {
disabled: PropTypes.string, disabled: PropTypes.string,
autoComplete: PropTypes.string, autoComplete: PropTypes.string,
tooltip: PropTypes.string, tooltip: PropTypes.string,
onKeyDown: PropTypes.func,
normalizeOnBlur: PropTypes.func,
meta: PropTypes.shape({ meta: PropTypes.shape({
touched: PropTypes.bool, touched: PropTypes.bool,
error: PropTypes.object, error: PropTypes.object,
}).isRequired, }).isRequired,
}; };
const FORM_NAMES = {
search: 'search',
response_status: 'response_status',
};
const Form = (props) => { const Form = (props) => {
const { const {
className = '', className = '',
responseStatusClass, responseStatusClass,
submit,
reset,
setIsLoading, setIsLoading,
change,
} = props; } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useDispatch(); const dispatch = useDispatch();
const history = useHistory();
const debouncedSubmit = debounce(submit, DEBOUNCE_FILTER_TIMEOUT); const {
const zeroDelaySubmit = () => setTimeout(submit, 0); response_status, search,
} = useSelector((state) => state.form[FORM_NAME.LOGS_FILTER].values, shallowEqual);
const clearInput = async () => { const [
await dispatch(setLogsFilter(DEFAULT_LOGS_FILTER)); debouncedSearch,
await reset(); setDebouncedSearch,
}; ] = useDebounce(search.trim(), DEBOUNCE_FILTER_TIMEOUT);
useEffect(() => {
dispatch(setLogsFilter({
response_status,
search: debouncedSearch,
}));
history.replace(`${getLogsUrlParams(debouncedSearch, response_status)}`);
}, [response_status, debouncedSearch]);
if (response_status && !(response_status in RESPONSE_FILTER_QUERIES)) {
change(FORM_NAMES.response_status, DEFAULT_LOGS_FILTER[FORM_NAMES.response_status]);
}
const onInputClear = async () => { const onInputClear = async () => {
setIsLoading(true); setIsLoading(true);
await clearInput(); setDebouncedSearch(DEFAULT_LOGS_FILTER[FORM_NAMES.search]);
change(FORM_NAMES.search, DEFAULT_LOGS_FILTER[FORM_NAMES.search]);
setIsLoading(false); setIsLoading(false);
}; };
useEffect(() => clearInput, []); const onEnterPress = (e) => {
if (e.key === 'Enter') {
setDebouncedSearch(search);
}
};
const normalizeOnBlur = (data) => data.trim();
return ( return (
<form className="d-flex flex-wrap form-control--container" <form className="d-flex flex-wrap form-control--container"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
zeroDelaySubmit();
debouncedSubmit.cancel();
}} }}
> >
<Field <Field
id="search" id={FORM_NAMES.search}
name="search" name={FORM_NAMES.search}
component={renderFilterField} component={renderFilterField}
type="text" type="text"
className={classNames('form-control--search form-control--transparent', className)} className={classNames('form-control--search form-control--transparent', className)}
placeholder={t('domain_or_client')} placeholder={t('domain_or_client')}
tooltip={t('query_log_strict_search')} tooltip={t('query_log_strict_search')}
onChange={debouncedSubmit}
onClearInputClick={onInputClear} onClearInputClick={onInputClear}
onKeyDown={onEnterPress}
normalizeOnBlur={normalizeOnBlur}
/> />
<div className="field__select"> <div className="field__select">
<Field <Field
name="response_status" name={FORM_NAMES.response_status}
component="select" component="select"
className={classNames('form-control custom-select custom-select--logs custom-select__arrow--left ml-small form-control--transparent', responseStatusClass)} className={classNames('form-control custom-select custom-select--logs custom-select__arrow--left ml-small form-control--transparent', responseStatusClass)}
onChange={zeroDelaySubmit}
> >
{Object.values(RESPONSE_FILTER) {Object.values(RESPONSE_FILTER)
.map(({ .map(({
@ -136,14 +174,13 @@ const Form = (props) => {
}; };
Form.propTypes = { Form.propTypes = {
handleChange: PropTypes.func,
className: PropTypes.string, className: PropTypes.string,
responseStatusClass: PropTypes.string, responseStatusClass: PropTypes.string,
submit: PropTypes.func.isRequired, change: PropTypes.func.isRequired,
reset: PropTypes.func.isRequired,
setIsLoading: PropTypes.func.isRequired, setIsLoading: PropTypes.func.isRequired,
}; };
export default reduxForm({ export default reduxForm({
form: FORM_NAME.LOGS_FILTER, form: FORM_NAME.LOGS_FILTER,
enableReinitialize: true,
})(Form); })(Form);

View File

@ -1,20 +1,9 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Trans } from 'react-i18next'; import { Trans } from 'react-i18next';
import { useDispatch } from 'react-redux';
import Form from './Form'; import Form from './Form';
import { setLogsFilter } from '../../../actions/queryLogs';
const Filters = ({ filter, refreshLogs, setIsLoading }) => { const Filters = ({ filter, refreshLogs, setIsLoading }) => (
const dispatch = useDispatch();
const onSubmit = async (values) => {
setIsLoading(true);
await dispatch(setLogsFilter(values));
setIsLoading(false);
};
return (
<div className="page-header page-header--logs"> <div className="page-header page-header--logs">
<h1 className="page-title page-title--large"> <h1 className="page-title page-title--large">
<Trans>query_log</Trans> <Trans>query_log</Trans>
@ -27,17 +16,14 @@ const Filters = ({ filter, refreshLogs, setIsLoading }) => {
<use xlinkHref="#update" /> <use xlinkHref="#update" />
</svg> </svg>
</button> </button>
</h1> </h1>
<Form <Form
responseStatusClass="d-sm-block" responseStatusClass="d-sm-block"
initialValues={filter} initialValues={filter}
onSubmit={onSubmit}
setIsLoading={setIsLoading} setIsLoading={setIsLoading}
/> />
</div> </div>
); );
};
Filters.propTypes = { Filters.propTypes = {
filter: PropTypes.object.isRequired, filter: PropTypes.object.isRequired,

View File

@ -49,7 +49,7 @@ const Table = (props) => {
isLoading, isLoading,
} = props; } = props;
const [t] = useTranslation(); const { t } = useTranslation();
const toggleBlocking = (type, domain) => { const toggleBlocking = (type, domain) => {
const { const {
@ -239,7 +239,7 @@ const Table = (props) => {
sortable={false} sortable={false}
resizable={false} resizable={false}
data={logs || []} data={logs || []}
loading={isLoading} loading={isLoading || processingGetLogs}
showPageJump={false} showPageJump={false}
showPageSizeOptions={false} showPageSizeOptions={false}
onPageChange={changePage} onPageChange={changePage}

View File

@ -2,11 +2,14 @@ import React, { Fragment, useEffect, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Trans } from 'react-i18next'; import { Trans } from 'react-i18next';
import Modal from 'react-modal'; import Modal from 'react-modal';
import { useDispatch } from 'react-redux'; import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import queryString from 'query-string';
import { import {
BLOCK_ACTIONS, smallScreenSize, BLOCK_ACTIONS,
TABLE_DEFAULT_PAGE_SIZE, TABLE_DEFAULT_PAGE_SIZE,
TABLE_FIRST_PAGE, TABLE_FIRST_PAGE,
smallScreenSize,
} from '../../helpers/constants'; } from '../../helpers/constants';
import Loading from '../ui/Loading'; import Loading from '../ui/Loading';
import Filters from './Filters'; import Filters from './Filters';
@ -15,13 +18,15 @@ import Disabled from './Disabled';
import { getFilteringStatus } from '../../actions/filtering'; import { getFilteringStatus } from '../../actions/filtering';
import { getClients } from '../../actions'; import { getClients } from '../../actions';
import { getDnsConfig } from '../../actions/dnsConfig'; import { getDnsConfig } from '../../actions/dnsConfig';
import { getLogsConfig } from '../../actions/queryLogs'; import {
getLogsConfig,
refreshFilteredLogs,
resetFilteredLogs,
setFilteredLogs,
} from '../../actions/queryLogs';
import { addSuccessToast } from '../../actions/toasts'; import { addSuccessToast } from '../../actions/toasts';
import './Logs.css'; import './Logs.css';
const INITIAL_REQUEST = true;
const INITIAL_REQUEST_DATA = ['', TABLE_FIRST_PAGE, INITIAL_REQUEST];
export const processContent = (data, buttonType) => Object.entries(data) export const processContent = (data, buttonType) => Object.entries(data)
.map(([key, value]) => { .map(([key, value]) => {
if (!value) { if (!value) {
@ -56,22 +61,44 @@ export const processContent = (data, buttonType) => Object.entries(data)
const Logs = (props) => { const Logs = (props) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const history = useHistory();
const {
response_status: response_status_url_param = '',
search: search_url_param = '',
} = queryString.parse(history.location.search);
const { filter } = useSelector((state) => state.queryLogs, shallowEqual);
const search = filter?.search || search_url_param;
const response_status = filter?.response_status || response_status_url_param;
const [isSmallScreen, setIsSmallScreen] = useState(window.innerWidth < smallScreenSize); const [isSmallScreen, setIsSmallScreen] = useState(window.innerWidth < smallScreenSize);
const [detailedDataCurrent, setDetailedDataCurrent] = useState({}); const [detailedDataCurrent, setDetailedDataCurrent] = useState({});
const [buttonType, setButtonType] = useState(BLOCK_ACTIONS.BLOCK); const [buttonType, setButtonType] = useState(BLOCK_ACTIONS.BLOCK);
const [isModalOpened, setModalOpened] = useState(false); const [isModalOpened, setModalOpened] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
(async () => {
setIsLoading(true);
await dispatch(setFilteredLogs({
search,
response_status,
}));
setIsLoading(false);
})();
}, [response_status, search]);
const { const {
filtering, filtering,
setLogsPage, setLogsPage,
setLogsPagination, setLogsPagination,
setLogsFilter,
toggleDetailedLogs, toggleDetailedLogs,
dashboard, dashboard,
dnsConfig, dnsConfig,
queryLogs: { queryLogs: {
filter,
enabled, enabled,
processingGetConfig, processingGetConfig,
processingAdditionalLogs, processingAdditionalLogs,
@ -92,16 +119,10 @@ const Logs = (props) => {
} }
}; };
useEffect(() => {
mediaQuery.addListener(mediaQueryHandler);
return () => mediaQuery.removeListener(mediaQueryHandler);
}, []);
const closeModal = () => setModalOpened(false); const closeModal = () => setModalOpened(false);
const getLogs = (older_than, page, initial) => { const getLogs = (older_than, page, initial) => {
if (props.queryLogs.enabled) { if (enabled) {
props.getLogs({ props.getLogs({
older_than, older_than,
page, page,
@ -112,6 +133,8 @@ const Logs = (props) => {
}; };
useEffect(() => { useEffect(() => {
mediaQuery.addEventListener('change', mediaQueryHandler);
(async () => { (async () => {
setIsLoading(true); setIsLoading(true);
dispatch(setLogsPage(TABLE_FIRST_PAGE)); dispatch(setLogsPage(TABLE_FIRST_PAGE));
@ -119,7 +142,6 @@ const Logs = (props) => {
dispatch(getClients()); dispatch(getClients());
try { try {
await Promise.all([ await Promise.all([
getLogs(...INITIAL_REQUEST_DATA),
dispatch(getLogsConfig()), dispatch(getLogsConfig()),
dispatch(getDnsConfig()), dispatch(getDnsConfig()),
]); ]);
@ -129,13 +151,18 @@ const Logs = (props) => {
setIsLoading(false); setIsLoading(false);
} }
})(); })();
return () => {
mediaQuery.removeEventListener('change', mediaQueryHandler);
dispatch(resetFilteredLogs());
};
}, []); }, []);
const refreshLogs = async () => { const refreshLogs = async () => {
setIsLoading(true); setIsLoading(true);
await Promise.all([ await Promise.all([
dispatch(setLogsPage(TABLE_FIRST_PAGE)), dispatch(setLogsPage(TABLE_FIRST_PAGE)),
getLogs(...INITIAL_REQUEST_DATA), dispatch(refreshFilteredLogs()),
]); ]);
dispatch(addSuccessToast('query_log_updated')); dispatch(addSuccessToast('query_log_updated'));
setIsLoading(false); setIsLoading(false);
@ -145,13 +172,15 @@ const Logs = (props) => {
<> <>
{enabled && processingGetConfig && <Loading />} {enabled && processingGetConfig && <Loading />}
{enabled && !processingGetConfig && ( {enabled && !processingGetConfig && (
<Fragment> <>
<Filters <Filters
filter={filter} filter={{
response_status,
search,
}}
setIsLoading={setIsLoading} setIsLoading={setIsLoading}
processingGetLogs={processingGetLogs} processingGetLogs={processingGetLogs}
processingAdditionalLogs={processingAdditionalLogs} processingAdditionalLogs={processingAdditionalLogs}
setLogsFilter={setLogsFilter}
refreshLogs={refreshLogs} refreshLogs={refreshLogs}
/> />
<Table <Table
@ -201,7 +230,7 @@ const Logs = (props) => {
</svg> </svg>
{processContent(detailedDataCurrent, buttonType)} {processContent(detailedDataCurrent, buttonType)}
</Modal> </Modal>
</Fragment> </>
)} )}
{!enabled && !processingGetConfig && ( {!enabled && !processingGetConfig && (
<Disabled /> <Disabled />
@ -219,7 +248,6 @@ Logs.propTypes = {
setRules: PropTypes.func.isRequired, setRules: PropTypes.func.isRequired,
addSuccessToast: PropTypes.func.isRequired, addSuccessToast: PropTypes.func.isRequired,
setLogsPagination: PropTypes.func.isRequired, setLogsPagination: PropTypes.func.isRequired,
setLogsFilter: PropTypes.func.isRequired,
setLogsPage: PropTypes.func.isRequired, setLogsPage: PropTypes.func.isRequired,
toggleDetailedLogs: PropTypes.func.isRequired, toggleDetailedLogs: PropTypes.func.isRequired,
dnsConfig: PropTypes.object.isRequired, dnsConfig: PropTypes.object.isRequired,

View File

@ -7,6 +7,7 @@ import Card from '../../ui/Card';
import CellWrap from '../../ui/CellWrap'; import CellWrap from '../../ui/CellWrap';
import whoisCell from './whoisCell'; import whoisCell from './whoisCell';
import LogsSearchLink from '../../ui/LogsSearchLink';
const COLUMN_MIN_WIDTH = 200; const COLUMN_MIN_WIDTH = 200;
@ -49,7 +50,9 @@ class AutoClients extends Component {
return ( return (
<div className="logs__row"> <div className="logs__row">
<div className="logs__text" title={clientStats}> <div className="logs__text" title={clientStats}>
{clientStats} <LogsSearchLink search={row.original.ip}>
{clientStats}
</LogsSearchLink>
</div> </div>
</div> </div>
); );

View File

@ -8,6 +8,7 @@ import { normalizeTextarea } from '../../../helpers/helpers';
import Card from '../../ui/Card'; import Card from '../../ui/Card';
import Modal from './Modal'; import Modal from './Modal';
import CellWrap from '../../ui/CellWrap'; import CellWrap from '../../ui/CellWrap';
import LogsSearchLink from '../../ui/LogsSearchLink';
class ClientsTable extends Component { class ClientsTable extends Component {
handleFormAdd = (values) => { handleFormAdd = (values) => {
@ -49,7 +50,10 @@ class ClientsTable extends Component {
}; };
getOptionsWithLabels = (options) => ( getOptionsWithLabels = (options) => (
options.map((option) => ({ value: option, label: option })) options.map((option) => ({
value: option,
label: option,
}))
); );
getClient = (name, clients) => { getClient = (name, clients) => {
@ -203,7 +207,15 @@ class ClientsTable extends Component {
accessor: (row) => this.props.normalizedTopClients.configured[row.name] || 0, accessor: (row) => this.props.normalizedTopClients.configured[row.name] || 0,
sortMethod: (a, b) => b - a, sortMethod: (a, b) => b - a,
minWidth: 120, minWidth: 120,
Cell: CellWrap, Cell: (row) => {
const content = CellWrap(row);
if (!row.value) {
return content;
}
return <LogsSearchLink search={row.original.ids[0]}>{content}</LogsSearchLink>;
},
}, },
{ {
Header: this.props.t('actions_table_header'), Header: this.props.t('actions_table_header'),
@ -311,7 +323,6 @@ class ClientsTable extends Component {
> >
<Trans>client_add</Trans> <Trans>client_add</Trans>
</button> </button>
<Modal <Modal
isModalOpen={isModalOpen} isModalOpen={isModalOpen}
modalType={modalType} modalType={modalType}

View File

@ -1,30 +1,32 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import LogsSearchLink from './LogsSearchLink';
import { formatNumber } from '../../helpers/helpers'; import { formatNumber } from '../../helpers/helpers';
const Cell = ({ value, percent, color }) => ( const Cell = ({
<div className="stats__row"> value, percent, color, search,
<div className="stats__row-value mb-1"> }) => <div className="stats__row">
<strong>{formatNumber(value)}</strong> <div className="stats__row-value mb-1">
<small className="ml-3 text-muted">{percent}%</small> <strong><LogsSearchLink search={search}>{formatNumber(value)}</LogsSearchLink></strong>
</div> <small className="ml-3 text-muted">{percent}%</small>
<div className="progress progress-xs">
<div
className="progress-bar"
style={{
width: `${percent}%`,
backgroundColor: color,
}}
/>
</div>
</div> </div>
); <div className="progress progress-xs">
<div
className="progress-bar"
style={{
width: `${percent}%`,
backgroundColor: color,
}}
/>
</div>
</div>;
Cell.propTypes = { Cell.propTypes = {
value: PropTypes.number.isRequired, value: PropTypes.number.isRequired,
percent: PropTypes.number.isRequired, percent: PropTypes.number.isRequired,
color: PropTypes.string.isRequired, color: PropTypes.string.isRequired,
search: PropTypes.string,
onSearchRedirect: PropTypes.func,
}; };
export default Cell; export default Cell;

View File

@ -0,0 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import './IconTooltip.css';
import { useTranslation } from 'react-i18next';
const IconTooltip = ({ text, type = '' }) => {
const { t } = useTranslation();
return <div data-tooltip={t(text)}
className={`tooltip-custom ml-1 ${type}`} />;
};
IconTooltip.propTypes = {
text: PropTypes.string.isRequired,
type: PropTypes.string,
};
export default IconTooltip;

View File

@ -0,0 +1,7 @@
.stats__link {
color: inherit;
}
.stats__link:hover {
cursor: pointer;
}

View File

@ -0,0 +1,33 @@
import React from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import './LogsSearchLink.css';
import { getLogsUrlParams } from '../../helpers/helpers';
import { MENU_URLS } from '../../helpers/constants';
const LogsSearchLink = ({
search = '', response_status = '', children, link = MENU_URLS.logs,
}) => {
const { t } = useTranslation();
const to = link === MENU_URLS.logs ? `${MENU_URLS.logs}${getLogsUrlParams(search && `"${search}"`, response_status)}` : link;
return <Link to={to}
className={'stats__link'}
tabIndex={0}
title={t('click_to_view_queries')}
aria-label={t('click_to_view_queries')}>{children}</Link>;
};
LogsSearchLink.propTypes = {
children: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.element]).isRequired,
search: PropTypes.string,
response_status: PropTypes.string,
link: PropTypes.string,
};
export default LogsSearchLink;

View File

@ -1,14 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import './Tooltip.css';
const Tooltip = ({ text, type = '' }) => <div data-tooltip={text}
className={`tooltip-custom ml-1 ${type}`} />;
Tooltip.propTypes = {
text: PropTypes.string.isRequired,
type: PropTypes.string,
};
export default Tooltip;

View File

@ -1,7 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { getFilteringStatus, setRules } from '../actions/filtering'; import { getFilteringStatus, setRules } from '../actions/filtering';
import { import {
getLogs, setLogsPagination, setLogsFilter, setLogsPage, toggleDetailedLogs, getLogs, setLogsPagination, setLogsPage, toggleDetailedLogs,
} from '../actions/queryLogs'; } from '../actions/queryLogs';
import Logs from '../components/Logs'; import Logs from '../components/Logs';
import { addSuccessToast } from '../actions/toasts'; import { addSuccessToast } from '../actions/toasts';
@ -26,7 +26,6 @@ const mapDispatchToProps = {
setRules, setRules,
addSuccessToast, addSuccessToast,
setLogsPagination, setLogsPagination,
setLogsFilter,
setLogsPage, setLogsPage,
toggleDetailedLogs, toggleDetailedLogs,
}; };

View File

@ -397,6 +397,11 @@ export const RESPONSE_FILTER = {
}, },
}; };
export const RESPONSE_FILTER_QUERIES = Object.values(RESPONSE_FILTER).reduce((acc, { query }) => {
acc[query] = query;
return acc;
}, {});
export const FILTERED_STATUS_TO_META_MAP = { export const FILTERED_STATUS_TO_META_MAP = {
[FILTERED_STATUS.NOT_FILTERED_WHITE_LIST]: { [FILTERED_STATUS.NOT_FILTERED_WHITE_LIST]: {
label: RESPONSE_FILTER.ALLOWED.label, label: RESPONSE_FILTER.ALLOWED.label,

View File

@ -11,6 +11,7 @@ import axios from 'axios';
import i18n from 'i18next'; import i18n from 'i18next';
import uniqBy from 'lodash/uniqBy'; import uniqBy from 'lodash/uniqBy';
import ipaddr from 'ipaddr.js'; import ipaddr from 'ipaddr.js';
import queryString from 'query-string';
import versionCompare from './versionCompare'; import versionCompare from './versionCompare';
import { getTrackerData } from './trackers/trackers'; import { getTrackerData } from './trackers/trackers';
@ -618,6 +619,16 @@ export const selectCompletedFields = (values) => Object.entries(values)
return acc; return acc;
}, {}); }, {});
/**
* @param {string} search
* @param {string} [response_status]
* @returns {string}
*/
export const getLogsUrlParams = (search, response_status) => `?${queryString.stringify({
search,
response_status,
})}`;
export const processContent = (content) => (Array.isArray(content) export const processContent = (content) => (Array.isArray(content)
? content.filter(([, value]) => value) ? content.filter(([, value]) => value)

View File

@ -0,0 +1,22 @@
import { useState, useEffect } from 'react';
const useDebounce = (value, delay) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(
() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
},
[value, delay],
);
return [debouncedValue, setDebouncedValue];
};
export default useDebounce;

View File

@ -25,14 +25,14 @@ const queryLogs = handleActions(
page: payload, page: payload,
}), }),
[actions.setLogsFilterRequest]: (state) => ({ ...state, processingGetLogs: true }), [actions.setFilteredLogsRequest]: (state) => ({ ...state, processingGetLogs: true }),
[actions.setLogsFilterFailure]: (state) => ({ ...state, processingGetLogs: false }), [actions.setFilteredLogsFailure]: (state) => ({ ...state, processingGetLogs: false }),
[actions.toggleDetailedLogs]: (state, { payload }) => ({ [actions.toggleDetailedLogs]: (state, { payload }) => ({
...state, ...state,
isDetailed: payload, isDetailed: payload,
}), }),
[actions.setLogsFilterSuccess]: (state, { payload }) => { [actions.setFilteredLogsSuccess]: (state, { payload }) => {
const { logs, oldest, filter } = payload; const { logs, oldest, filter } = payload;
const pageSize = TABLE_DEFAULT_PAGE_SIZE; const pageSize = TABLE_DEFAULT_PAGE_SIZE;
const page = 0; const page = 0;
@ -57,6 +57,12 @@ const queryLogs = handleActions(
}; };
}, },
[actions.setLogsFilterRequest]: (state, { payload }) => {
const { filter } = payload;
return { ...state, filter };
},
[actions.getLogsRequest]: (state) => ({ ...state, processingGetLogs: true }), [actions.getLogsRequest]: (state) => ({ ...state, processingGetLogs: true }),
[actions.getLogsFailure]: (state) => ({ ...state, processingGetLogs: false }), [actions.getLogsFailure]: (state) => ({ ...state, processingGetLogs: false }),
[actions.getLogsSuccess]: (state, { payload }) => { [actions.getLogsSuccess]: (state, { payload }) => {