+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

@@ -36,7 +36,7 @@ import i18n from '../../i18n';
import Loading from '../ui/Loading';
import { FILTERS_URLS, MENU_URLS, SETTINGS_URLS } from '../../helpers/constants';
import Services from '../Filters/Services';
import { setHtmlLangAttr } from '../../helpers/helpers';
import { getLogsUrlParams, setHtmlLangAttr } from '../../helpers/helpers';
class App extends Component {
componentDidMount() {
@@ -111,7 +111,9 @@ class App extends Component {
{!dashboard.processing && dashboard.isCoreRunning && (
<>
<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={SETTINGS_URLS.settings} component={Settings} />
<Route path={SETTINGS_URLS.dns} component={Dns} />

View File

@@ -14,7 +14,11 @@ const CountCell = (totalBlocked) => function cell(row) {
const { value } = row;
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 = ({

View File

@@ -25,7 +25,7 @@ const countCell = (dnsQueries) => function cell(row) {
const percent = getPercent(dnsQueries, value);
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) => {

View File

@@ -1,31 +1,80 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Trans, withTranslation } from 'react-i18next';
import propTypes from 'prop-types';
import { Trans, useTranslation } from 'react-i18next';
import round from 'lodash/round';
import { shallowEqual, useSelector } from 'react-redux';
import Card from '../ui/Card';
import Tooltip from '../ui/Tooltip';
import IconTooltip from '../ui/IconTooltip';
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 {
t,
interval,
refreshButton,
subtitle,
dnsQueries,
blockedFiltering,
replacedSafebrowsing,
replacedParental,
replacedSafesearch,
numDnsQueries,
numBlockedFiltering,
numReplacedSafebrowsing,
numReplacedParental,
numReplacedSafesearch,
avgProcessingTime,
} = props;
} = useSelector((state) => state.stats, shallowEqual);
const { t } = useTranslation();
const tooltipTitle = interval === 1
? t('number_of_dns_query_24_hours')
: t('number_of_dns_query_days', { count: interval });
const rows = [
{
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 (
<Card
@@ -35,104 +84,23 @@ const Counters = (props) => {
refresh={refreshButton}
>
<table className="table card-table">
<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>
<tbody>{rows.map(Row)}</tbody>
</table>
</Card>
);
};
Counters.propTypes = {
dnsQueries: PropTypes.number.isRequired,
blockedFiltering: PropTypes.number.isRequired,
replacedSafebrowsing: PropTypes.number.isRequired,
replacedParental: PropTypes.number.isRequired,
replacedSafesearch: PropTypes.number.isRequired,
avgProcessingTime: PropTypes.number.isRequired,
refreshButton: PropTypes.node.isRequired,
subtitle: PropTypes.string.isRequired,
interval: PropTypes.number.isRequired,
t: PropTypes.func.isRequired,
Row.propTypes = {
label: propTypes.string.isRequired,
count: propTypes.string.isRequired,
response_status: propTypes.string,
tooltipTitle: propTypes.string.isRequired,
translationComponents: propTypes.arrayOf(propTypes.element),
};
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) => {
if (percent > 10) {
return STATUS_COLORS.red;
} if (percent > 5) {
}
if (percent > 5) {
return STATUS_COLORS.yellow;
}
return STATUS_COLORS.green;
@@ -24,7 +25,8 @@ const countCell = (dnsQueries) => function cell(row) {
const percent = getPercent(dnsQueries, value);
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 = ({

View File

@@ -111,13 +111,6 @@ class Dashboard extends Component {
<div className="col-lg-6">
<Counters
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}
/>
</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 PropTypes from 'prop-types';
import enhanceWithClickOutside from 'react-click-outside';
import classnames from 'classnames';
import { Trans, withTranslation } from 'react-i18next';
import { SETTINGS_URLS, FILTERS_URLS, MENU_URLS } from '../../helpers/constants';
import Dropdown from '../ui/Dropdown';
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
@@ -18,27 +21,63 @@ const MENU_ITEMS = [
// 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 = [
{ route: SETTINGS_URLS.settings, text: 'general_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' },
{
route: SETTINGS_URLS.settings,
text: 'general_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 = [
{ route: FILTERS_URLS.dns_blocklists, text: 'dns_blocklists' },
{ 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' },
{
route: FILTERS_URLS.dns_blocklists,
text: 'dns_blocklists',
},
{
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 {
@@ -52,7 +91,8 @@ class Menu extends Component {
getActiveClassForDropdown = (URLS) => {
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' : '';
};
@@ -79,18 +119,18 @@ class Menu extends Component {
getDropdown = ({
label, order, URLS, icon, ITEMS,
}) => (
<Dropdown
label={this.props.t(label)}
baseClassName={`dropdown nav-item order-${order}`}
controlClassName={`nav-link ${this.getActiveClassForDropdown(URLS)}`}
icon={icon}>
{ITEMS.map((item) => (
this.getNavLink({
...item,
order,
className: 'dropdown-item',
})))}
</Dropdown>
<Dropdown
label={this.props.t(label)}
baseClassName='dropdown'
controlClassName={`nav-link ${this.getActiveClassForDropdown(URLS)}`}
icon={icon}>
{ITEMS.map((item) => (
this.getNavLink({
...item,
order,
className: 'dropdown-item',
})))}
</Dropdown>
);
render() {
@@ -99,7 +139,7 @@ class Menu extends Component {
'mobile-menu--active': this.props.isMenuOpen,
});
return (
<Fragment>
<>
<div className={menuClass}>
<ul className="nav nav-tabs border-0 flex-column flex-lg-row flex-nowrap">
{MENU_ITEMS.map((item) => (
@@ -108,26 +148,33 @@ class Menu extends Component {
key={item.text}
onClick={this.closeMenu}
>
{this.getNavLink({ ...item, className: 'nav-link' })}
{this.getNavLink({
...item,
className: 'nav-link',
})}
</li>
))}
{this.getDropdown({
order: 1,
label: 'settings',
icon: 'settings',
URLS: SETTINGS_URLS,
ITEMS: SETTINGS_ITEMS,
})}
{this.getDropdown({
order: 2,
label: 'filters',
icon: 'filters',
URLS: FILTERS_URLS,
ITEMS: FILTERS_ITEMS,
})}
<li className="nav-item order-1">
{this.getDropdown({
order: 1,
label: 'settings',
icon: 'settings',
URLS: SETTINGS_URLS,
ITEMS: SETTINGS_ITEMS,
})}
</li>
<li className="nav-item order-2">
{this.getDropdown({
order: 2,
label: 'filters',
icon: 'filters',
URLS: FILTERS_URLS,
ITEMS: FILTERS_ITEMS,
})}
</li>
</ul>
</div>
</Fragment>
</>
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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