Pull request 743: + client: Query Logs Infinite Scroll

Merge in DNS/adguard-home from feature/infinite_scroll_query_logs to master

Squashed commit of the following:

commit 4407ef2e7c055066257da791fbd65e6b0a495729
Merge: 40b74522 0a4781be
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Tue Sep 1 16:20:23 2020 +0300

    Merge branch 'master' into feature/infinite_scroll_query_logs

commit 40b745225112cf8d664220ed8f484b0aa16e997c
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Tue Sep 1 15:46:27 2020 +0300

    Remove dynamic translation of toasts

commit f08fa7b8c6a243f6b10e924aebccc183ce7814fd
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Tue Sep 1 13:59:53 2020 +0300

    Remove renderLimitIdx, update isEntireLog

commit 0f1b02616faaa5759c0a3f6d8257117fa22094d9
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Tue Sep 1 11:11:14 2020 +0300

    Rename variables

commit 0928570c689c1fa704af775382620d68893e7c1c
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Tue Sep 1 11:06:50 2020 +0300

    Make query logs short polling function more expressive

commit 9e773cbd6c287a1c799fa2680f3462508462ea7a
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Tue Sep 1 11:06:19 2020 +0300

    Fix Toast translation interface

commit f9c57033e5adc5788954cf086b2f114dd8938bcb
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Mon Aug 31 17:01:36 2020 +0300

    Do not hide loader

commit b86ba48613437f5559a748ad9aa4cf79d15db082
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Mon Aug 31 16:56:34 2020 +0300

    Add dynamic translation for all toasts

commit b9d1d9b447ca90a3c179e503fa5d4abd3516321e
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Mon Aug 31 16:39:29 2020 +0300

    Prevent getting query logs recursion if query is not changed

commit e25189749f7912648cca4503cfa8d0ad898c4bb6
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Mon Aug 31 10:13:20 2020 +0300

    Decrease page limit to 20

commit 8b248ac5276899de838abf2dc9a69e47599cfc12
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Fri Aug 28 18:47:12 2020 +0300

    Return checkFilteredLogs

commit bf2d65c4a3dca0da6b15f632ae11042b7c8e2045
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Fri Aug 28 18:33:51 2020 +0300

    Review changes

commit 01b5250f9d9136a1f334086d3e2f00d1a928b37b
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Fri Aug 28 15:29:59 2020 +0300

    Remove checkFilteredLogs

commit 25b364c41e6a1489d930c8b3b39b1ab43723f29d
Merge: 1dc66034 2c666cbd
Author: Andrey Meshkov <am@adguard.com>
Date:   Fri Aug 28 14:28:47 2020 +0300

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

commit 1dc6603421cde9847e792bfe77ff6546e53fbc2a
Author: Andrey Meshkov <am@adguard.com>
Date:   Fri Aug 28 14:28:01 2020 +0300

    disregard maxFileScanEntries only if offset is set

commit bad741ed7f1dccf6959d43d000b8c0150f526f9e
Author: Andrey Meshkov <am@adguard.com>
Date:   Fri Aug 28 11:57:45 2020 +0300

    Fix search behavior when limit is specified

commit 2c666cbdde465cf17434126830dd99ceedfc4cbc
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Thu Aug 27 18:50:28 2020 +0300

    Hide table ref loader during data loading

commit 8b4f7fe642ef9e87a979813dcdbd7817d64c27f9
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Thu Aug 27 18:43:24 2020 +0300

    Repair search

commit 26fae1ae01a789999b8a2181d60b35663a20460a
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Thu Aug 27 17:59:27 2020 +0300

    Resetting initial render index, change loader position on search

commit e2c97ae1a288438267eef9aec71b979319674a71
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Thu Aug 27 16:02:03 2020 +0300

    Change isScrolledIntoView

... and 32 more commits
This commit is contained in:
Artem Baskal
2020-09-01 16:30:30 +03:00
parent 0a4781be97
commit 6b61429572
74 changed files with 1449 additions and 1861 deletions

View File

@@ -0,0 +1,109 @@
import React from 'react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { nanoid } from 'nanoid';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import propTypes from 'prop-types';
import { checkFiltered } from '../../../helpers/helpers';
import { BLOCK_ACTIONS } from '../../../helpers/constants';
import { toggleBlocking } from '../../../actions';
import IconTooltip from './IconTooltip';
import { renderFormattedClientCell } from '../../../helpers/renderFormattedClientCell';
const ClientCell = ({
client,
domain,
info,
info: { name, whois_info },
reason,
}) => {
const { t } = useTranslation();
const dispatch = useDispatch();
const autoClients = useSelector((state) => state.dashboard.autoClients, shallowEqual);
const processingRules = useSelector((state) => state.filtering.processingRules);
const isDetailed = useSelector((state) => state.queryLogs.isDetailed);
const autoClient = autoClients.find((autoClient) => autoClient.name === client);
const source = autoClient?.source;
const whoisAvailable = whois_info && Object.keys(whois_info).length > 0;
const id = nanoid();
const data = {
address: client,
name,
country: whois_info?.country,
city: whois_info?.city,
network: whois_info?.orgname,
source_label: source,
};
const processedData = Object.entries(data);
const isFiltered = checkFiltered(reason);
const nameClass = classNames('w-90 o-hidden d-flex flex-column', {
'mt-2': isDetailed && !name && !whoisAvailable,
'white-space--nowrap': isDetailed,
});
const hintClass = classNames('icons mr-4 icon--24 icon--lightgray', {
'my-3': isDetailed,
});
const renderBlockingButton = (isFiltered, domain) => {
const buttonType = isFiltered ? BLOCK_ACTIONS.UNBLOCK : BLOCK_ACTIONS.BLOCK;
const buttonClass = classNames('btn btn-sm logs__cell--block-button', {
'btn-outline-secondary': isFiltered,
'btn-outline-danger': !isFiltered,
});
const onClick = () => dispatch(toggleBlocking(buttonType, domain));
return <button
type="button"
className={buttonClass}
onClick={onClick}
disabled={processingRules}
>
{t(buttonType)}
</button>;
};
return <div className="o-hidden h-100 logs__cell logs__cell--client" role="gridcell">
<IconTooltip className={hintClass} columnClass='grid grid--limited' tooltipClass='px-5 pb-5 pt-4 mw-75'
xlinkHref='question' contentItemClass="contentItemClass" title="client_details"
content={processedData} placement="bottom" />
<div className={nameClass}>
<div data-tip={true} data-for={id}>
{renderFormattedClientCell(client, info, isDetailed, true)}
</div>
{isDetailed && name && !whoisAvailable
&& <div className="detailed-info d-none d-sm-block logs__text"
title={name}>
{name}
</div>}
</div>
{renderBlockingButton(isFiltered, domain)}
</div>;
};
ClientCell.propTypes = {
client: propTypes.string.isRequired,
domain: propTypes.string.isRequired,
info: propTypes.oneOfType([
propTypes.string,
propTypes.shape({
name: propTypes.string.isRequired,
whois_info: propTypes.shape({
country: propTypes.string,
city: propTypes.string,
orgname: propTypes.string,
}),
}),
]),
reason: propTypes.string.isRequired,
};
export default ClientCell;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { useSelector } from 'react-redux';
import propTypes from 'prop-types';
import { formatDateTime, formatTime } from '../../../helpers/helpers';
import { DEFAULT_SHORT_DATE_FORMAT_OPTIONS, DEFAULT_TIME_FORMAT } from '../../../helpers/constants';
const DateCell = ({ time }) => {
const isDetailed = useSelector((state) => state.queryLogs.isDetailed);
if (!time) {
return '';
}
const formattedTime = formatTime(time, DEFAULT_TIME_FORMAT);
const formattedDate = formatDateTime(time, DEFAULT_SHORT_DATE_FORMAT_OPTIONS);
return <div className="logs__cell logs__cell logs__cell--date text-truncate" role="gridcell">
<div className="logs__time" title={formattedTime}>{formattedTime}</div>
{isDetailed
&& <div className="detailed-info d-none d-sm-block text-truncate"
title={formattedDate}>{formattedDate}</div>}
</div>;
};
DateCell.propTypes = {
time: propTypes.string.isRequired,
};
export default DateCell;

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { useSelector } from 'react-redux';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import getIconTooltip from './getIconTooltip';
import propTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import {
DEFAULT_SHORT_DATE_FORMAT_OPTIONS,
LONG_TIME_FORMAT,
@@ -9,15 +10,19 @@ import {
} from '../../../helpers/constants';
import { captitalizeWords, formatDateTime, formatTime } from '../../../helpers/helpers';
import { getSourceData } from '../../../helpers/trackers/trackers';
import IconTooltip from './IconTooltip';
const getDomainCell = (props) => {
const {
row, t, isDetailed, dnssec_enabled,
} = props;
const {
tracker, type, answer_dnssec, client_proto, domain, time,
} = row.original;
const DomainCell = ({
answer_dnssec,
client_proto,
domain,
time,
tracker,
type,
}) => {
const { t } = useTranslation();
const dnssec_enabled = useSelector((state) => state.dnsConfig.dnssec_enabled);
const isDetailed = useSelector((state) => state.queryLogs.isDetailed);
const hasTracker = !!tracker;
@@ -50,8 +55,8 @@ const getDomainCell = (props) => {
name_table_header: tracker?.name,
category_label: hasTracker && captitalizeWords(tracker.category),
source_label: sourceData
&& <a href={sourceData.url} target="_blank" rel="noopener noreferrer"
className="link--green">{sourceData.name}</a>,
&& <a href={sourceData.url} target="_blank" rel="noopener noreferrer"
className="link--green">{sourceData.name}</a>,
};
const renderGrid = (content, idx) => {
@@ -72,51 +77,42 @@ const getDomainCell = (props) => {
const renderContent = hasTracker ? requestDetails.concat(getGrid(knownTrackerDataObj, 'known_tracker', 'pt-4')) : requestDetails;
const trackerHint = getIconTooltip({
className: privacyIconClass,
tooltipClass: 'pt-4 pb-5 px-5 mw-75',
xlinkHref: 'privacy',
contentItemClass: 'key-colon',
renderContent,
place: 'bottom',
});
const valueClass = classNames('w-100', {
const valueClass = classNames('w-100 text-truncate', {
'px-2 d-flex justify-content-center flex-column': isDetailed,
});
const details = [ip, protocol].filter(Boolean)
.join(', ');
return (
<div className="logs__row o-hidden">
{dnssec_enabled && getIconTooltip({
className: lockIconClass,
tooltipClass: 'py-4 px-5 pb-45',
canShowTooltip: answer_dnssec,
xlinkHref: 'lock',
columnClass: 'w-100',
content: 'validated_with_dnssec',
placement: 'bottom',
})}
{trackerHint}
<div className={valueClass}>
<div className="text-truncate" title={domain}>{domain}</div>
{details && isDetailed
&& <div className="detailed-info d-none d-sm-block text-truncate"
title={details}>{details}</div>}
</div>
return <div className="d-flex o-hidden logs__cell logs__cell logs__cell--domain" role="gridcell">
{dnssec_enabled && <IconTooltip
className={lockIconClass}
tooltipClass='py-4 px-5 pb-45'
canShowTooltip={!!answer_dnssec}
xlinkHref='lock'
columnClass='w-100'
content='validated_with_dnssec'
placement='bottom'
/>}
<IconTooltip className={privacyIconClass} tooltipClass='pt-4 pb-5 px-5 mw-75'
xlinkHref='privacy' contentItemClass='key-colon' renderContent={renderContent}
place='bottom' />
<div className={valueClass}>
<div className="text-truncate" title={domain}>{domain}</div>
{details && isDetailed
&& <div className="detailed-info d-none d-sm-block text-truncate"
title={details}>{details}</div>}
</div>
);
</div>;
};
getDomainCell.propTypes = {
row: PropTypes.object.isRequired,
t: PropTypes.func.isRequired,
isDetailed: PropTypes.bool.isRequired,
toggleBlocking: PropTypes.func.isRequired,
autoClients: PropTypes.array.isRequired,
dnssec_enabled: PropTypes.bool.isRequired,
DomainCell.propTypes = {
answer_dnssec: propTypes.bool.isRequired,
client_proto: propTypes.string.isRequired,
domain: propTypes.string.isRequired,
time: propTypes.string.isRequired,
type: propTypes.string.isRequired,
tracker: propTypes.object,
};
export default getDomainCell;
export default DomainCell;

View File

@@ -0,0 +1,54 @@
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import classNames from 'classnames';
import React from 'react';
import { toggleDetailedLogs } from '../../../actions/queryLogs';
import HeaderCell from './HeaderCell';
const Header = () => {
const { t } = useTranslation();
const dispatch = useDispatch();
const isDetailed = useSelector((state) => state.queryLogs.isDetailed);
const disableDetailedMode = () => dispatch(toggleDetailedLogs(false));
const enableDetailedMode = () => dispatch(toggleDetailedLogs(true));
const HEADERS = [
{
className: 'logs__cell--date',
content: 'time_table_header',
},
{
className: 'logs__cell--domain',
content: 'request_table_header',
},
{
className: 'logs__cell--response',
content: 'response_table_header',
},
{
className: 'logs__cell--client',
content: <>
{t('client_table_header')}
{<span>
<svg className={classNames('icons icon--24 icon--green cursor--pointer mr-2', { 'icon--selected': !isDetailed })}
onClick={disableDetailedMode}
>
<title>{t('compact')}</title>
<use xlinkHref='#list' /></svg>
<svg className={classNames('icons icon--24 icon--green cursor--pointer', { 'icon--selected': isDetailed })}
onClick={enableDetailedMode}
>
<title>{t('default')}</title>
<use xlinkHref='#detailed_list' />
</svg>
</span>}
</>,
},
];
return <div className="logs__cell--header__container px-5" role="row">
{HEADERS.map(HeaderCell)}
</div>;
};
export default Header;

View File

@@ -0,0 +1,22 @@
import classNames from 'classnames';
import React from 'react';
import propTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
const HeaderCell = ({ content, className }, idx) => {
const { t } = useTranslation();
return <div
key={idx}
className={classNames('logs__cell--header__item logs__cell logs__text--bold', className)}
role="columnheader"
>
{typeof content === 'string' ? t(content) : content}
</div>;
};
HeaderCell.propTypes = {
content: propTypes.oneOfType([propTypes.string, propTypes.element]).isRequired,
className: propTypes.string,
};
export default HeaderCell;

View File

@@ -7,7 +7,7 @@ import Tooltip from '../../ui/Tooltip';
import 'react-popper-tooltip/dist/styles.css';
import './IconTooltip.css';
const getIconTooltip = ({
const IconTooltip = ({
className,
contentItemClass,
columnClass,
@@ -43,14 +43,14 @@ const getIconTooltip = ({
</Tooltip>;
};
getIconTooltip.propTypes = {
IconTooltip.propTypes = {
className: PropTypes.string,
contentItemClass: PropTypes.string,
columnClass: PropTypes.string,
tooltipClass: PropTypes.string,
title: PropTypes.string,
placement: PropTypes.string,
canShowTooltip: PropTypes.string,
canShowTooltip: PropTypes.bool,
xlinkHref: PropTypes.string,
content: PropTypes.oneOfType([
PropTypes.string,
@@ -59,4 +59,4 @@ getIconTooltip.propTypes = {
renderContent: PropTypes.arrayOf(PropTypes.element),
};
export default getIconTooltip;
export default IconTooltip;

View File

@@ -0,0 +1,101 @@
import { useTranslation } from 'react-i18next';
import { shallowEqual, useSelector } from 'react-redux';
import classNames from 'classnames';
import React from 'react';
import propTypes from 'prop-types';
import { formatElapsedMs, getFilterName } from '../../../helpers/helpers';
import { FILTERED_STATUS, FILTERED_STATUS_TO_META_MAP } from '../../../helpers/constants';
import IconTooltip from './IconTooltip';
const ResponseCell = ({
elapsedMs,
originalResponse,
reason,
response,
status,
upstream,
rule,
filterId,
}) => {
const { t } = useTranslation();
const filters = useSelector((state) => state.filtering.filters, shallowEqual);
const whitelistFilters = useSelector((state) => state.filtering.whitelistFilters, shallowEqual);
const isDetailed = useSelector((state) => state.queryLogs.isDetailed);
const formattedElapsedMs = formatElapsedMs(elapsedMs, t);
const isBlocked = reason === FILTERED_STATUS.FILTERED_BLACK_LIST
|| reason === FILTERED_STATUS.FILTERED_BLOCKED_SERVICE;
const isBlockedByResponse = originalResponse.length > 0 && isBlocked;
const statusLabel = t(isBlockedByResponse ? 'blocked_by_cname_or_ip' : FILTERED_STATUS_TO_META_MAP[reason]?.LABEL || reason);
const boldStatusLabel = <span className="font-weight-bold">{statusLabel}</span>;
const filter = getFilterName(filters, whitelistFilters, filterId);
const renderResponses = (responseArr) => {
if (!responseArr || responseArr.length === 0) {
return '';
}
return <div>{responseArr.map((response) => {
const className = classNames('white-space--nowrap', {
'overflow-break': response.length > 100,
});
return <div key={response} className={className}>{`${response}\n`}</div>;
})}</div>;
};
const COMMON_CONTENT = {
encryption_status: boldStatusLabel,
install_settings_dns: upstream,
elapsed: formattedElapsedMs,
response_code: status,
filter,
rule_label: rule,
response_table_header: renderResponses(response),
original_response: renderResponses(originalResponse),
};
const content = rule
? Object.entries(COMMON_CONTENT)
: Object.entries({
...COMMON_CONTENT,
filter: '',
});
const detailedInfo = isBlocked ? filter : formattedElapsedMs;
return <div className="logs__cell logs__cell--response" role="gridcell">
<IconTooltip
className={classNames('icons mr-4 icon--24 icon--lightgray', { 'my-3': isDetailed })}
columnClass='grid grid--limited'
tooltipClass='px-5 pb-5 pt-4 mw-75 custom-tooltip__response-details'
contentItemClass='text-truncate key-colon o-hidden'
xlinkHref='question'
title='response_details'
content={content}
placement='bottom'
/>
<div className="text-truncate">
<div className="text-truncate" title={statusLabel}>{statusLabel}</div>
{isDetailed && <div
className="detailed-info d-none d-sm-block pt-1 text-truncate"
title={detailedInfo}>{detailedInfo}</div>}
</div>
</div>;
};
ResponseCell.propTypes = {
elapsedMs: propTypes.string.isRequired,
originalResponse: propTypes.array.isRequired,
reason: propTypes.string.isRequired,
response: propTypes.array.isRequired,
status: propTypes.string.isRequired,
upstream: propTypes.string.isRequired,
rule: propTypes.string,
filterId: propTypes.number,
};
export default ResponseCell;

View File

@@ -1,110 +0,0 @@
import React from 'react';
import { nanoid } from 'nanoid';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { formatClientCell } from '../../../helpers/formatClientCell';
import getIconTooltip from './getIconTooltip';
import { checkFiltered } from '../../../helpers/helpers';
import { BLOCK_ACTIONS } from '../../../helpers/constants';
const getClientCell = ({
row, t, isDetailed, toggleBlocking, autoClients, processingRules,
}) => {
const {
reason, client, domain, info: { name, whois_info },
} = row.original;
const autoClient = autoClients.find((autoClient) => autoClient.name === client);
const source = autoClient?.source;
const whoisAvailable = whois_info && Object.keys(whois_info).length > 0;
const id = nanoid();
const data = {
address: client,
name,
country: whois_info?.country,
city: whois_info?.city,
network: whois_info?.orgname,
source_label: source,
};
const processedData = Object.entries(data);
const isFiltered = checkFiltered(reason);
const nameClass = classNames('w-90 o-hidden d-flex flex-column', {
'mt-2': isDetailed && !name && !whoisAvailable,
'white-space--nowrap': isDetailed,
});
const hintClass = classNames('icons mr-4 icon--24 icon--lightgray', {
'my-3': isDetailed,
});
const renderBlockingButton = (isFiltered, domain) => {
const buttonType = isFiltered ? BLOCK_ACTIONS.UNBLOCK : BLOCK_ACTIONS.BLOCK;
const buttonClass = classNames('logs__action button__action', {
'btn-outline-secondary': isFiltered,
'btn-outline-danger': !isFiltered,
'logs__action--detailed': isDetailed,
});
const onClick = () => toggleBlocking(buttonType, domain);
return (
<div className={buttonClass}>
<button
type="button"
className={`btn btn-sm ${buttonClass}`}
onClick={onClick}
disabled={processingRules}
>
{t(buttonType)}
</button>
</div>
);
};
return (
<div className="logs__row o-hidden h-100">
{getIconTooltip({
className: hintClass,
columnClass: 'grid grid--limited',
tooltipClass: 'px-5 pb-5 pt-4 mw-75',
xlinkHref: 'question',
contentItemClass: 'text-truncate key-colon',
title: 'client_details',
content: processedData,
placement: 'bottom',
})}
<div className={nameClass}>
<div data-tip={true} data-for={id}>
{formatClientCell(row, isDetailed)}
</div>
{isDetailed && name && !whoisAvailable && (
<div
className="detailed-info d-none d-sm-block logs__text"
title={name}
>
{name}
</div>
)}
</div>
{renderBlockingButton(isFiltered, domain)}
</div>
);
};
getClientCell.propTypes = {
row: PropTypes.object.isRequired,
t: PropTypes.func.isRequired,
isDetailed: PropTypes.bool.isRequired,
toggleBlocking: PropTypes.func.isRequired,
autoClients: PropTypes.array.isRequired,
processingRules: PropTypes.bool.isRequired,
};
export default getClientCell;

View File

@@ -1,28 +0,0 @@
import React from 'react';
import { formatTime, formatDateTime } from '../../../helpers/helpers';
import {
DEFAULT_SHORT_DATE_FORMAT_OPTIONS,
DEFAULT_TIME_FORMAT,
} from '../../../helpers/constants';
const getDateCell = (row, isDetailed) => {
const { time } = row.original;
if (!time) {
return '';
}
const formattedTime = formatTime(time, DEFAULT_TIME_FORMAT);
const formattedDate = formatDateTime(time, DEFAULT_SHORT_DATE_FORMAT_OPTIONS);
return (
<div className="logs__cell">
<div className="logs__time" title={formattedTime}>{formattedTime}</div>
{isDetailed && <div className="detailed-info d-none d-sm-block text-truncate"
title={formattedDate}>{formattedDate}</div>}
</div>
);
};
export default getDateCell;

View File

@@ -1,79 +0,0 @@
import React from 'react';
import classNames from 'classnames';
import { formatElapsedMs } from '../../../helpers/helpers';
import {
FILTERED_STATUS,
FILTERED_STATUS_TO_META_MAP,
} from '../../../helpers/constants';
import getIconTooltip from './getIconTooltip';
const getResponseCell = (row, filtering, t, isDetailed, getFilterName) => {
const {
reason, filterId, rule, status, upstream, elapsedMs, response, originalResponse,
} = row.original;
const { filters, whitelistFilters } = filtering;
const formattedElapsedMs = formatElapsedMs(elapsedMs, t);
const isBlocked = reason === FILTERED_STATUS.FILTERED_BLACK_LIST
|| reason === FILTERED_STATUS.FILTERED_BLOCKED_SERVICE;
const isBlockedByResponse = originalResponse.length > 0 && isBlocked;
const statusLabel = t(isBlockedByResponse ? 'blocked_by_cname_or_ip' : FILTERED_STATUS_TO_META_MAP[reason]?.label || reason);
const boldStatusLabel = <span className="font-weight-bold">{statusLabel}</span>;
const filter = getFilterName(filters, whitelistFilters, filterId, t);
const renderResponses = (responseArr) => {
if (responseArr?.length === 0) {
return '';
}
return <div>{responseArr.map((response) => {
const className = classNames('white-space--nowrap', {
'overflow-break': response.length > 100,
});
return <div key={response} className={className}>{`${response}\n`}</div>;
})}</div>;
};
const COMMON_CONTENT = {
encryption_status: boldStatusLabel,
install_settings_dns: upstream,
elapsed: formattedElapsedMs,
response_code: status,
filter,
rule_label: rule,
response_table_header: renderResponses(response),
original_response: renderResponses(originalResponse),
};
const content = rule
? Object.entries(COMMON_CONTENT)
: Object.entries({ ...COMMON_CONTENT, filter: '' });
const detailedInfo = isBlocked ? filter : formattedElapsedMs;
return (
<div className="logs__row">
{getIconTooltip({
className: classNames('icons mr-4 icon--24 icon--lightgray', { 'my-3': isDetailed }),
columnClass: 'grid grid--limited',
tooltipClass: 'px-5 pb-5 pt-4 mw-75 custom-tooltip__response-details',
contentItemClass: 'text-truncate key-colon o-hidden',
xlinkHref: 'question',
title: 'response_details',
content,
placement: 'bottom',
})}
<div className="text-truncate">
<div className="text-truncate" title={statusLabel}>{statusLabel}</div>
{isDetailed && <div
className="detailed-info d-none d-sm-block pt-1 text-truncate"
title={detailedInfo}>{detailedInfo}</div>}
</div>
</div>
);
};
export default getResponseCell;

View File

@@ -0,0 +1,197 @@
import React, { memo } from 'react';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import propTypes from 'prop-types';
import {
captitalizeWords,
checkFiltered,
formatDateTime,
formatElapsedMs,
formatTime,
getFilterName,
processContent,
} from '../../../helpers/helpers';
import {
BLOCK_ACTIONS,
DEFAULT_SHORT_DATE_FORMAT_OPTIONS,
FILTERED_STATUS,
FILTERED_STATUS_TO_META_MAP,
LONG_TIME_FORMAT,
QUERY_STATUS_COLORS,
SCHEME_TO_PROTOCOL_MAP,
} from '../../../helpers/constants';
import { getSourceData } from '../../../helpers/trackers/trackers';
import { toggleBlocking } from '../../../actions';
import DateCell from './DateCell';
import DomainCell from './DomainCell';
import ResponseCell from './ResponseCell';
import ClientCell from './ClientCell';
import '../Logs.css';
const Row = memo(({
style,
rowProps,
rowProps: { reason },
isSmallScreen,
setDetailedDataCurrent,
setButtonType,
setModalOpened,
}) => {
const dispatch = useDispatch();
const { t } = useTranslation();
const dnssec_enabled = useSelector((state) => state.dnsConfig.dnssec_enabled);
const filters = useSelector((state) => state.filtering.filters, shallowEqual);
const whitelistFilters = useSelector((state) => state.filtering.whitelistFilters, shallowEqual);
const autoClients = useSelector((state) => state.dashboard.autoClients, shallowEqual);
const onClick = () => {
if (!isSmallScreen) { return; }
const {
answer_dnssec,
client,
domain,
elapsedMs,
info,
reason,
response,
time,
tracker,
upstream,
type,
client_proto,
filterId,
rule,
originalResponse,
status,
} = rowProps;
const hasTracker = !!tracker;
const autoClient = autoClients
.find((autoClient) => autoClient.name === client);
const { whois_info } = info;
const country = whois_info?.country;
const city = whois_info?.city;
const network = whois_info?.orgname;
const source = autoClient?.source;
const formattedElapsedMs = formatElapsedMs(elapsedMs, t);
const isFiltered = checkFiltered(reason);
const isBlocked = reason === FILTERED_STATUS.FILTERED_BLACK_LIST
|| reason === FILTERED_STATUS.FILTERED_BLOCKED_SERVICE;
const buttonType = isFiltered ? BLOCK_ACTIONS.UNBLOCK : BLOCK_ACTIONS.BLOCK;
const onToggleBlock = () => {
dispatch(toggleBlocking(buttonType, domain));
};
const isBlockedByResponse = originalResponse.length > 0 && isBlocked;
const requestStatus = t(isBlockedByResponse ? 'blocked_by_cname_or_ip' : FILTERED_STATUS_TO_META_MAP[reason]?.LABEL || reason);
const protocol = t(SCHEME_TO_PROTOCOL_MAP[client_proto]) || '';
const sourceData = getSourceData(tracker);
const filter = getFilterName(filters, whitelistFilters, filterId);
const detailedData = {
time_table_header: formatTime(time, LONG_TIME_FORMAT),
date: formatDateTime(time, DEFAULT_SHORT_DATE_FORMAT_OPTIONS),
encryption_status: isBlocked
? <div className="bg--danger">{requestStatus}</div> : requestStatus,
domain,
type_table_header: type,
protocol,
known_tracker: hasTracker && 'title',
table_name: tracker?.name,
category_label: hasTracker && captitalizeWords(tracker.category),
tracker_source: hasTracker && sourceData
&& <a
href={sourceData.url}
target="_blank"
rel="noopener noreferrer"
className="link--green">{sourceData.name}
</a>,
response_details: 'title',
install_settings_dns: upstream,
elapsed: formattedElapsedMs,
filter: rule ? filter : null,
rule_label: rule,
response_table_header: response?.join('\n'),
response_code: status,
client_details: 'title',
ip_address: client,
name: info?.name,
country,
city,
network,
source_label: source,
validated_with_dnssec: dnssec_enabled ? Boolean(answer_dnssec) : false,
original_response: originalResponse?.join('\n'),
[buttonType]: <div onClick={onToggleBlock}
className={classNames('title--border text-center', {
'bg--danger': isBlocked,
})}>{t(buttonType)}</div>,
};
setDetailedDataCurrent(processContent(detailedData));
setButtonType(buttonType);
setModalOpened(true);
};
const isDetailed = useSelector((state) => state.queryLogs.isDetailed);
const className = classNames('d-flex px-5 logs__row',
`logs__row--${FILTERED_STATUS_TO_META_MAP?.[reason]?.COLOR ?? QUERY_STATUS_COLORS.WHITE}`, {
'logs__cell--detailed': isDetailed,
});
return <div style={style} className={className} onClick={onClick} role="row">
<DateCell {...rowProps} />
<DomainCell {...rowProps} />
<ResponseCell {...rowProps} />
<ClientCell {...rowProps} />
</div>;
});
Row.displayName = 'Row';
Row.propTypes = {
style: propTypes.object,
rowProps: propTypes.shape({
reason: propTypes.string.isRequired,
answer_dnssec: propTypes.bool.isRequired,
client: propTypes.string.isRequired,
domain: propTypes.string.isRequired,
elapsedMs: propTypes.string.isRequired,
info: propTypes.oneOfType([
propTypes.string,
propTypes.shape({
whois_info: propTypes.shape({
country: propTypes.string,
city: propTypes.string,
orgname: propTypes.string,
}),
})]),
response: propTypes.array.isRequired,
time: propTypes.string.isRequired,
tracker: propTypes.object,
upstream: propTypes.string.isRequired,
type: propTypes.string.isRequired,
client_proto: propTypes.string.isRequired,
filterId: propTypes.number,
rule: propTypes.string,
originalResponse: propTypes.array,
status: propTypes.string.isRequired,
}).isRequired,
isSmallScreen: propTypes.bool.isRequired,
setDetailedDataCurrent: propTypes.func.isRequired,
setButtonType: propTypes.func.isRequired,
setModalOpened: propTypes.func.isRequired,
};
export default Row;

View File

@@ -107,7 +107,7 @@ const Form = (props) => {
const {
response_status, search,
} = useSelector((state) => state.form[FORM_NAME.LOGS_FILTER].values, shallowEqual);
} = useSelector((state) => state?.form[FORM_NAME.LOGS_FILTER].values, shallowEqual);
const [
debouncedSearch,
@@ -171,14 +171,14 @@ const Form = (props) => {
>
{Object.values(RESPONSE_FILTER)
.map(({
query, label, disabled,
QUERY, LABEL, disabled,
}) => (
<option
key={label}
value={query}
key={LABEL}
value={QUERY}
disabled={disabled}
>
{t(label)}
{t(LABEL)}
</option>
))
}
@@ -197,5 +197,4 @@ Form.propTypes = {
export default reduxForm({
form: FORM_NAME.LOGS_FILTER,
enableReinitialize: true,
})(Form);

View File

@@ -1,10 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import Form from './Form';
import { refreshFilteredLogs } from '../../../actions/queryLogs';
import { addSuccessToast } from '../../../actions/toasts';
const Filters = ({ filter, refreshLogs, setIsLoading }) => {
const Filters = ({ filter, setIsLoading }) => {
const { t } = useTranslation();
const dispatch = useDispatch();
const refreshLogs = async () => {
setIsLoading(true);
await dispatch(refreshFilteredLogs());
dispatch(addSuccessToast('query_log_updated'));
setIsLoading(false);
};
return <div className="page-header page-header--logs">
<h1 className="page-title page-title--large">
@@ -29,7 +40,6 @@ const Filters = ({ filter, refreshLogs, setIsLoading }) => {
Filters.propTypes = {
filter: PropTypes.object.isRequired,
refreshLogs: PropTypes.func.isRequired,
processingGetLogs: PropTypes.bool.isRequired,
setIsLoading: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1,87 @@
import React, {
useCallback,
useEffect,
useRef,
} from 'react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import propTypes from 'prop-types';
import throttle from 'lodash/throttle';
import Loading from '../ui/Loading';
import Header from './Cells/Header';
import { getLogs } from '../../actions/queryLogs';
import Row from './Cells';
import { isScrolledIntoView } from '../../helpers/helpers';
import { QUERY_LOGS_PAGE_LIMIT } from '../../helpers/constants';
const InfiniteTable = ({
isLoading,
items,
isSmallScreen,
setDetailedDataCurrent,
setButtonType,
setModalOpened,
}) => {
const { t } = useTranslation();
const dispatch = useDispatch();
const loader = useRef(null);
const {
isEntireLog,
processingGetLogs,
} = useSelector((state) => state.queryLogs, shallowEqual);
const loading = isLoading || processingGetLogs;
const listener = useCallback(() => {
if (loader.current && isScrolledIntoView(loader.current)) {
dispatch(getLogs());
}
}, [loader.current, isScrolledIntoView, getLogs]);
useEffect(() => {
listener();
}, [items.length < QUERY_LOGS_PAGE_LIMIT]);
useEffect(() => {
const THROTTLE_TIME = 100;
const throttledListener = throttle(listener, THROTTLE_TIME);
window.addEventListener('scroll', throttledListener);
return () => {
window.removeEventListener('scroll', throttledListener);
};
}, []);
const renderRow = (row, idx) => <Row
key={idx}
rowProps={row}
isSmallScreen={isSmallScreen}
setDetailedDataCurrent={setDetailedDataCurrent}
setButtonType={setButtonType}
setModalOpened={setModalOpened}
/>;
const isNothingFound = items.length === 0 && !processingGetLogs;
return <div className='logs__table' role='grid'>
{loading && <Loading />}
<Header />
{isNothingFound
? <label className="logs__no-data">{t('nothing_found')}</label>
: <>{items.map(renderRow)}
{!isEntireLog && <div ref={loader} className="logs__loading text-center">{t('loading_table_status')}</div>}
</>}
</div>;
};
InfiniteTable.propTypes = {
isLoading: propTypes.bool.isRequired,
items: propTypes.array.isRequired,
isSmallScreen: propTypes.bool.isRequired,
setDetailedDataCurrent: propTypes.func.isRequired,
setButtonType: propTypes.func.isRequired,
setModalOpened: propTypes.func.isRequired,
};
export default InfiniteTable;

View File

@@ -1,44 +1,21 @@
:root {
--blue: #e5effd;
--green-pale: rgba(103, 178, 121, 0.1);
--red: rgba(223, 56, 18, 0.05);
--white: #fff;
--yellow: rgba(247, 181, 0, 0.1);
--size-date: 70;
--size-domain: 180;
--size-response: 150;
--size-client: 123;
--gray-216: rgba(216, 216, 216, 0.23);
--gray-4d: #4D4D4D;
--gray-8: #888;
--danger: #DF3812;
--white80: rgba(255, 255, 255, 0.8);
}
.logs__row {
position: relative;
display: flex;
min-height: 26px;
overflow: hidden;
text-overflow: ellipsis;
}
.card-table .logs__row {
overflow: hidden;
text-overflow: ellipsis;
}
.logs__row--center {
justify-content: center;
}
.logs__row--column {
flex-direction: column;
align-items: flex-start;
justify-content: center;
}
.logs__row--icons {
max-width: 180px;
flex-flow: row wrap;
}
.logs__row .list-unstyled {
margin-bottom: 0;
overflow: hidden;
}
.logs__text,
.logs__row .list-unstyled li {
.logs__text {
padding: 0 1px;
text-overflow: ellipsis;
white-space: nowrap;
@@ -54,237 +31,6 @@
font-weight: bold;
}
.logs__text--full {
width: 100%;
}
.logs__text--wrap {
line-height: 1.4;
white-space: normal;
}
.logs__text--nowrap {
line-height: 1.4;
white-space: nowrap;
}
.logs__text--whois {
line-height: 1.2;
color: #9aa0ac;
}
.logs__row .tooltip-custom {
top: 0;
margin-left: 0;
margin-right: 5px;
}
.tooltip__option {
height: 2.5rem !important;
width: 10.5rem;
padding: 0.3125rem 1.5rem 0.6875rem;
}
.tooltip__option:hover {
background-color: var(--gray-f3);
cursor: pointer;
}
.button__action {
background-color: #fff;
border-radius: 4px;
transition: opacity 0.2s ease, visibility 0.2s ease;
visibility: hidden;
opacity: 0;
}
.table__action {
position: absolute;
top: 11px;
right: 15px;
}
.logs__action {
position: absolute;
top: 0;
right: 1rem;
}
.logs__action--detailed {
top: 5px;
}
.logs__table .rt-td,
.clients__table .rt-td {
position: relative;
}
.logs__table .rt-thead, .logs__table .rt-tbody {
min-width: 100% !important;
}
.logs__table .rt-tr:hover .logs__action,
.clients__table .rt-tr:hover .table__action {
visibility: visible;
opacity: 1;
}
.logs__table .rt-tr-group:first-child .tooltip-custom:before {
top: calc(100% + 12px);
bottom: initial;
z-index: 1;
}
.logs__table .rt-tr-group:first-child .tooltip-custom:after {
top: initial;
bottom: -4px;
border-top: 6px solid transparent;
border-bottom: 6px solid #585965;
}
.logs__table .rt-tr-group:first-child .popover__body {
top: calc(100% + 5px);
bottom: initial;
z-index: 1;
}
.logs__table .rt-tr-group:first-child .popover__body:after {
top: -11px;
border-top: 6px solid transparent;
border-bottom: 6px solid #585965;
}
.logs__table .rt-thead.-filters input,
.logs__table .rt-thead.-filters select {
padding: 6px 7px;
border-radius: 3px;
font-size: 0.9375rem;
line-height: 1.6;
color: #495057;
border: 1px solid rgba(0, 40, 100, 0.12);
}
.logs__table .rt-thead.-filters select {
background: #fff url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAxMCA1Jz48cGF0aCBmaWxsPScjOTk5JyBkPSdNMCAwTDEwIDBMNSA1TDAgMCcvPjwvc3ZnPg==") no-repeat right 0.75rem center;
background-size: 8px 10px;
}
.logs__table .rt-thead.-filters input:focus,
.logs__table .rt-thead.-filters select:focus {
border-color: #1991eb;
box-shadow: 0 0 0 2px rgba(70, 127, 207, 0.25);
}
.logs__text-wrap {
display: flex;
align-items: center;
max-width: 100%;
}
.logs__list-wrap {
display: flex;
max-width: 100%;
}
.logs__list-item {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.logs__input-wrap {
position: relative;
}
.logs__whois {
display: inline;
font-size: 12px;
white-space: nowrap;
}
.logs__whois::after {
content: "|";
padding: 0 5px;
opacity: 0.3;
}
.logs__whois:last-child::after {
content: "";
}
.logs__whois-icon.icons {
position: relative;
top: -2px;
width: 12px;
height: 12px;
margin-right: 1px;
opacity: 0.5;
}
/* New logs */
.logs__table {
background-color: #fff;
border: 0;
border-radius: 8px;
min-height: 42rem;
max-width: 100%;
}
.logs__table--detailed {
min-height: 50rem;
}
.logs__table .rt-thead.-header {
box-shadow: none;
font-weight: bold;
}
.logs__table .rt-thead .rt-th {
padding: 0.9375rem 0.9375rem 0.875rem 0;
text-align: left;
border-right: 0;
}
.logs__table .rt-tbody .rt-td {
padding: 1rem 1rem 0.5rem 0;
border-right: 0;
}
.logs__table .rt-thead .rt-th:last-child,
.logs__table .rt-tbody .rt-td:last-child {
padding-right: 0;
}
.logs__table .rt-tbody .rt-tr-group {
border-bottom: 0;
}
.logs__table .rt-tr {
position: relative;
padding: 0 24px;
}
.logs__table .rt-tr {
position: relative;
padding: 0 1.5rem;
}
.logs__table .rt-tr-group:not(:first-child) .rt-tr:before {
content: "";
position: absolute;
left: 1.5rem;
right: 1.5rem;
top: 0;
width: calc(100% - 3rem);
height: 2px;
background-color: rgba(216, 216, 216, 0.23);
}
.logs__table .rt-tr-group:last-child .rt-tr:after,
.logs__table .rt-thead .rt-tr:after {
display: none;
}
.logs__time {
font-size: 1rem;
line-height: 1.5;
@@ -302,132 +48,24 @@
border-radius: 4px;
}
/* Hide 3 and 4 column on mobile */
.logs__table .rt-thead .rt-th:nth-child(3),
.logs__table .rt-thead .rt-th:nth-child(4),
.logs__table .rt-tbody .rt-td:nth-child(3),
.logs__table .rt-tbody .rt-td:nth-child(4) {
display: none;
}
@media screen and (min-width: 768px) {
.logs__table .rt-thead .rt-th:nth-child(3),
.logs__table .rt-thead .rt-th:nth-child(4),
.logs__table .rt-tbody .rt-td:nth-child(3),
.logs__table .rt-tbody .rt-td:nth-child(4) {
display: block;
}
}
.text-pre {
white-space: pre-wrap !important;
overflow-wrap: break-word;
overflow: visible;
}
.custom-pagination {
width: 11.875rem !important;
background-color: transparent;
box-shadow: none !important;
border: none !important;
align-items: center !important;
}
.custom-pagination--padding {
padding: 2.5rem 0 2.5rem !important;
}
.custom-pagination .-btn {
--side-size: 2rem;
background-color: transparent !important;
border: 1px solid var(--gray-d8) !important;
border-radius: 4px !important;
width: var(--side-size) !important;
height: var(--side-size) !important;
}
.custom-pagination .-btn:enabled:hover {
background-color: var(--gray-f3) !important;
}
.custom-pagination .-previous {
flex: 0 1 !important;
}
.custom-pagination .-next {
flex: 0 1 !important;
}
.custom-pagination .-btn {
display: flex !important;
}
.logs__table .-pageInfo {
--side-size: 2rem;
font-variant-numeric: tabular-nums !important;
background-color: transparent !important;
border: 1px solid var(--gray-d8) !important;
border-radius: 4px !important;
width: var(--side-size) !important;
height: var(--side-size) !important;
margin: 0 !important;
display: flex !important;
justify-content: center;
align-items: center;
}
.logs__table .pagination-bottom {
justify-content: center !important;
display: flex !important;
}
.logs__table .-center:before {
content: '...';
transform: translateY(-0.25rem);
margin: auto;
}
.logs__table .-center:after {
content: '...';
transform: translateY(-0.25rem);
margin: auto;
}
.icon--detailed-info {
position: absolute;
right: 0;
top: 0.5rem;
}
.link--green {
color: var(--green79);
}
.row--detailed {
height: 4.9rem
}
.w-90 {
max-width: 90% !important;
}
.h-85 {
height: 85% !important;
}
.pt-45 {
padding-top: 1.25rem !important;
}
.pb-45 {
padding-bottom: 1.25rem !important;
}
.py-45 {
padding-top: 1.25rem !important;
padding-bottom: 1.25rem !important;
}
.mh-100 {
max-height: 100% !important;
}
@@ -493,14 +131,6 @@
}
@media (max-width: 767.98px) {
.rt-tr .logs__row .logs__text {
max-width: calc(100% - 1.5rem);
}
.ml-small {
margin-left: 1.5rem;
}
.form-control--container {
width: 100%;
flex-direction: column;
@@ -517,38 +147,157 @@
}
}
@media (max-width: 575px) {
.logs__table .rt-tr {
height: 3.125rem;
@media screen and (max-width: 767.98px) {
.logs__table .logs__cell--response,
.logs__table .logs__cell--client {
display: none !important;
}
.logs__table .rt-tbody .rt-td {
padding: 0.625rem 1rem 0.875rem 0;
}
.logs__table {
min-height: 42rem;
}
}
.loading__container > .-loading-inner {
top: 10rem !important;
bottom: initial !important;
}
.loading__text {
transform: translateY(3rem);
}
.logs__refresh {
--size: 2.5rem;
position: relative;
top: 3px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
width: var(--size);
height: var(--size);
padding: 0;
margin-left: 15px;
margin-left: 0.9375rem;
background-color: transparent;
}
.logs__cell {
padding: 1rem 1rem 0.5rem 0;
}
.logs__cell--date {
width: 4.375rem;
flex: var(--size-date) 0 auto;
}
.logs__cell--domain {
width: 11.25rem;
flex: var(--size-domain) 0 auto;
}
.logs__cell--response {
width: 9.375rem;
flex: var(--size-response) 0 auto;
}
.logs__cell--client {
width: 7.6875rem;
flex: var(--size-client) 0 auto;
padding-right: 0;
}
.logs__cell--header__container > .logs__cell--header__item {
border-right: 0;
font-size: 1rem;
}
.logs__cell--header__container > .logs__cell--header__item:last-child {
padding-right: 0;
}
.logs__cell--block-button {
max-height: 1.75rem;
position: relative;
left: 10%;
top: 40%;
visibility: hidden;
}
.logs__row {
position: relative;
display: flex;
min-height: 26px;
overflow: hidden;
text-overflow: ellipsis;
}
.logs__table .logs__row {
border-bottom: 2px solid var(--gray-216);
}
.logs__table .logs__row:hover .logs__cell--block-button {
visibility: visible;
}
.logs__table .logs__row .logs__cell--block-button:disabled {
background-color: var(--white) !important;
}
/* QUERY_STATUS_COLORS */
.logs__row--blue {
background-color: var(--blue);
}
.logs__row--green {
background-color: var(--green-pale);
}
.logs__row--red {
background-color: var(--red);
}
.logs__row--white {
background-color: var(--white);
}
.logs__row--yellow {
background-color: var(--yellow);
}
.logs__no-data {
color: var(--gray-4d);
background-color: var(--white80);
pointer-events: none;
font-weight: bold;
text-align: center;
padding-top: 21rem;
display: block;
}
.logs__loading {
padding: 1rem 0;
}
.logs__table {
background-color: var(--white);
border: 0;
border-radius: 8px;
min-height: 43rem;
max-width: 100%;
align-items: stretch;
width: 100%;
border-collapse: collapse;
contain: layout;
overflow-x: hidden;
overflow-y: scroll;
will-change: scroll-position;
}
.logs__table .logs__cell--response,
.logs__table .logs__cell--client {
display: flex;
}
.logs__cell--header__container {
display: flex;
}
.logs__table > .logs__cell--header__container > .logs__cell--client {
display: flex;
justify-content: space-between;
}
.logs__table .loading:after {
top: 10%;
}
.logs__table .loading:before {
min-height: 100%;
}

View File

@@ -1,414 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useTranslation, Trans } from 'react-i18next';
import ReactTable from 'react-table';
import classNames from 'classnames';
import endsWith from 'lodash/endsWith';
import escapeRegExp from 'lodash/escapeRegExp';
import {
BLOCK_ACTIONS,
DEFAULT_SHORT_DATE_FORMAT_OPTIONS,
LONG_TIME_FORMAT,
FILTERED_STATUS_TO_META_MAP,
TABLE_DEFAULT_PAGE_SIZE,
SCHEME_TO_PROTOCOL_MAP,
CUSTOM_FILTERING_RULES_ID, FILTERED_STATUS,
} from '../../helpers/constants';
import getDateCell from './Cells/getDateCell';
import getDomainCell from './Cells/getDomainCell';
import getClientCell from './Cells/getClientCell';
import getResponseCell from './Cells/getResponseCell';
import {
captitalizeWords,
checkFiltered,
formatDateTime,
formatElapsedMs,
formatTime,
processContent,
} from '../../helpers/helpers';
import Loading from '../ui/Loading';
import { getSourceData } from '../../helpers/trackers/trackers';
const Table = (props) => {
const {
setDetailedDataCurrent,
setButtonType,
setModalOpened,
isSmallScreen,
setIsLoading,
filtering,
isDetailed,
toggleDetailedLogs,
setLogsPage,
setLogsPagination,
processingGetLogs,
logs,
pages,
page,
isLoading,
} = props;
const { t } = useTranslation();
const toggleBlocking = (type, domain) => {
const {
setRules, getFilteringStatus, addSuccessToast,
} = props;
const { userRules } = filtering;
const lineEnding = !endsWith(userRules, '\n') ? '\n' : '';
const baseRule = `||${domain}^$important`;
const baseUnblocking = `@@${baseRule}`;
const blockingRule = type === BLOCK_ACTIONS.BLOCK ? baseUnblocking : baseRule;
const unblockingRule = type === BLOCK_ACTIONS.BLOCK ? baseRule : baseUnblocking;
const preparedBlockingRule = new RegExp(`(^|\n)${escapeRegExp(blockingRule)}($|\n)`);
const preparedUnblockingRule = new RegExp(`(^|\n)${escapeRegExp(unblockingRule)}($|\n)`);
const matchPreparedBlockingRule = userRules.match(preparedBlockingRule);
const matchPreparedUnblockingRule = userRules.match(preparedUnblockingRule);
if (matchPreparedBlockingRule) {
setRules(userRules.replace(`${blockingRule}`, ''));
addSuccessToast(`${t('rule_removed_from_custom_filtering_toast')}: ${blockingRule}`);
} else if (!matchPreparedUnblockingRule) {
setRules(`${userRules}${lineEnding}${unblockingRule}\n`);
addSuccessToast(`${t('rule_added_to_custom_filtering_toast')}: ${unblockingRule}`);
} else if (matchPreparedUnblockingRule) {
addSuccessToast(`${t('rule_added_to_custom_filtering_toast')}: ${unblockingRule}`);
return;
} else if (!matchPreparedBlockingRule) {
addSuccessToast(`${t('rule_removed_from_custom_filtering_toast')}: ${blockingRule}`);
return;
}
getFilteringStatus();
};
const getFilterName = (filters, whitelistFilters, filterId, t) => {
if (filterId === CUSTOM_FILTERING_RULES_ID) {
return t('custom_filter_rules');
}
const filter = filters.find((filter) => filter.id === filterId)
|| whitelistFilters.find((filter) => filter.id === filterId);
let filterName = '';
if (filter) {
filterName = filter.name;
}
if (!filterName) {
filterName = t('unknown_filter', { filterId });
}
return filterName;
};
const columns = [
{
Header: t('time_table_header'),
accessor: 'time',
Cell: (row) => getDateCell(row, isDetailed),
minWidth: 70,
maxHeight: 60,
headerClassName: 'logs__text',
},
{
Header: t('request_table_header'),
accessor: 'domain',
Cell: (row) => {
const {
isDetailed,
autoClients,
dnssec_enabled,
} = props;
return getDomainCell({
row,
t,
isDetailed,
toggleBlocking,
autoClients,
dnssec_enabled,
});
},
minWidth: 180,
maxHeight: 60,
headerClassName: 'logs__text',
},
{
Header: t('response_table_header'),
accessor: 'response',
Cell: (row) => getResponseCell(
row,
filtering,
t,
isDetailed,
getFilterName,
),
minWidth: 150,
maxHeight: 60,
headerClassName: 'logs__text',
},
{
Header: function Header() {
return <div className="d-flex justify-content-between">
{t('client_table_header')}
{<span>
<svg
className={classNames('icons icon--24 icon--green mr-2 cursor--pointer', {
'icon--selected': !isDetailed,
})}
onClick={() => toggleDetailedLogs(false)}
>
<title><Trans>compact</Trans></title>
<use xlinkHref='#list' />
</svg>
<svg
className={classNames('icons icon--24 icon--green cursor--pointer', {
'icon--selected': isDetailed,
})}
onClick={() => toggleDetailedLogs(true)}
>
<title><Trans>default</Trans></title>
<use xlinkHref='#detailed_list' />
</svg>
</span>}
</div>;
},
accessor: 'client',
Cell: (row) => {
const {
isDetailed,
autoClients,
filtering: { processingRules },
} = props;
return getClientCell({
row,
t,
isDetailed,
toggleBlocking,
autoClients,
processingRules,
});
},
minWidth: 123,
maxHeight: 60,
headerClassName: 'logs__text',
className: 'pb-0',
},
];
const changePage = async (page) => {
setIsLoading(true);
const { oldest, getLogs, pages } = props;
const isLastPage = pages && (page + 1 === pages);
await Promise.all([
setLogsPage(page),
setLogsPagination({
page,
pageSize: TABLE_DEFAULT_PAGE_SIZE,
}),
].concat(isLastPage ? getLogs(oldest, page) : []));
setIsLoading(false);
};
const tableClass = classNames('logs__table', {
'logs__table--detailed': isDetailed,
});
return (
<ReactTable
manual
minRows={0}
page={page}
pages={pages}
columns={columns}
filterable={false}
sortable={false}
resizable={false}
data={logs || []}
loading={isLoading || processingGetLogs}
showPageJump={false}
showPageSizeOptions={false}
onPageChange={changePage}
className={tableClass}
defaultPageSize={TABLE_DEFAULT_PAGE_SIZE}
loadingText={
<>
<Loading />
<h6 className="loading__text">{t('loading_table_status')}</h6>
</>
}
getLoadingProps={() => ({ className: 'loading__container' })}
rowsText={t('rows_table_footer_text')}
noDataText={!processingGetLogs
&& <label className="logs__text logs__text--bold">{t('nothing_found')}</label>}
pageText=''
ofText=''
showPagination={logs.length > 0}
getPaginationProps={() => ({ className: 'custom-pagination custom-pagination--padding' })}
getTbodyProps={() => ({ className: 'd-block' })}
previousText={
<svg className="icons icon--24 icon--gray w-100 h-100 cursor--pointer">
<title><Trans>previous_btn</Trans></title>
<use xlinkHref="#arrow-left" />
</svg>}
nextText={
<svg className="icons icon--24 icon--gray w-100 h-100 cursor--pointer">
<title><Trans>next_btn</Trans></title>
<use xlinkHref="#arrow-right" />
</svg>}
renderTotalPagesCount={() => false}
getTrGroupProps={(_state, rowInfo) => {
if (!rowInfo) {
return {};
}
const { reason } = rowInfo.original;
const colorClass = FILTERED_STATUS_TO_META_MAP[reason] ? FILTERED_STATUS_TO_META_MAP[reason].color : 'white';
return { className: colorClass };
}}
getTrProps={(state, rowInfo) => ({
className: isDetailed ? 'row--detailed' : '',
onClick: () => {
if (isSmallScreen) {
const { dnssec_enabled, autoClients } = props;
const {
answer_dnssec,
client,
domain,
elapsedMs,
info,
reason,
response,
time,
tracker,
upstream,
type,
client_proto,
filterId,
rule,
originalResponse,
status,
} = rowInfo.original;
const hasTracker = !!tracker;
const autoClient = autoClients
.find((autoClient) => autoClient.name === client);
const { whois_info } = info;
const country = whois_info?.country;
const city = whois_info?.city;
const network = whois_info?.orgname;
const source = autoClient?.source;
const formattedElapsedMs = formatElapsedMs(elapsedMs, t);
const isFiltered = checkFiltered(reason);
const isBlocked = reason === FILTERED_STATUS.FILTERED_BLACK_LIST
|| reason === FILTERED_STATUS.FILTERED_BLOCKED_SERVICE;
const buttonType = isFiltered ? BLOCK_ACTIONS.UNBLOCK : BLOCK_ACTIONS.BLOCK;
const onToggleBlock = () => {
toggleBlocking(buttonType, domain);
};
const isBlockedByResponse = originalResponse.length > 0 && isBlocked;
const requestStatus = t(isBlockedByResponse ? 'blocked_by_cname_or_ip' : FILTERED_STATUS_TO_META_MAP[reason]?.label || reason);
const protocol = t(SCHEME_TO_PROTOCOL_MAP[client_proto]) || '';
const sourceData = getSourceData(tracker);
const { filters, whitelistFilters } = filtering;
const filter = getFilterName(filters, whitelistFilters, filterId, t);
const detailedData = {
time_table_header: formatTime(time, LONG_TIME_FORMAT),
date: formatDateTime(time, DEFAULT_SHORT_DATE_FORMAT_OPTIONS),
encryption_status: isBlocked
? <div className="bg--danger">{requestStatus}</div> : requestStatus,
domain,
type_table_header: type,
protocol,
known_tracker: hasTracker && 'title',
table_name: tracker?.name,
category_label: hasTracker && captitalizeWords(tracker.category),
tracker_source: hasTracker && sourceData
&& <a
href={sourceData.url}
target="_blank"
rel="noopener noreferrer"
className="link--green">{sourceData.name}
</a>,
response_details: 'title',
install_settings_dns: upstream,
elapsed: formattedElapsedMs,
filter: rule ? filter : null,
rule_label: rule,
response_table_header: response?.join('\n'),
response_code: status,
client_details: 'title',
ip_address: client,
name: info?.name,
country,
city,
network,
source_label: source,
validated_with_dnssec: dnssec_enabled ? Boolean(answer_dnssec) : false,
original_response: originalResponse?.join('\n'),
[buttonType]: <div onClick={onToggleBlock}
className={classNames('title--border text-center', {
'bg--danger': isBlocked,
})}>{t(buttonType)}</div>,
};
setDetailedDataCurrent(processContent(detailedData));
setButtonType(buttonType);
setModalOpened(true);
}
},
})}
/>
);
};
Table.propTypes = {
logs: PropTypes.array.isRequired,
pages: PropTypes.number.isRequired,
page: PropTypes.number.isRequired,
autoClients: PropTypes.array.isRequired,
defaultPageSize: PropTypes.number,
oldest: PropTypes.string.isRequired,
filtering: PropTypes.object.isRequired,
processingGetLogs: PropTypes.bool.isRequired,
processingGetConfig: PropTypes.bool.isRequired,
isDetailed: PropTypes.bool.isRequired,
setLogsPage: PropTypes.func.isRequired,
setLogsPagination: PropTypes.func.isRequired,
getLogs: PropTypes.func.isRequired,
toggleDetailedLogs: PropTypes.func.isRequired,
setRules: PropTypes.func.isRequired,
addSuccessToast: PropTypes.func.isRequired,
getFilteringStatus: PropTypes.func.isRequired,
isLoading: PropTypes.bool.isRequired,
setIsLoading: PropTypes.func.isRequired,
dnssec_enabled: PropTypes.bool.isRequired,
setDetailedDataCurrent: PropTypes.func.isRequired,
setButtonType: PropTypes.func.isRequired,
setModalOpened: PropTypes.func.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
};
export default Table;

View File

@@ -1,5 +1,4 @@
import React, { Fragment, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import React, { useEffect, useState } from 'react';
import { Trans } from 'react-i18next';
import Modal from 'react-modal';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
@@ -8,24 +7,21 @@ import queryString from 'query-string';
import classNames from 'classnames';
import {
BLOCK_ACTIONS,
TABLE_DEFAULT_PAGE_SIZE,
TABLE_FIRST_PAGE,
SMALL_SCREEN_SIZE,
} from '../../helpers/constants';
import Loading from '../ui/Loading';
import Filters from './Filters';
import Table from './Table';
import Disabled from './Disabled';
import { getFilteringStatus } from '../../actions/filtering';
import { getClients } from '../../actions';
import { getDnsConfig } from '../../actions/dnsConfig';
import {
getLogsConfig,
refreshFilteredLogs,
resetFilteredLogs,
setFilteredLogs,
toggleDetailedLogs,
} from '../../actions/queryLogs';
import { addSuccessToast } from '../../actions/toasts';
import InfiniteTable from './InfiniteTable';
import './Logs.css';
const processContent = (data, buttonType) => Object.entries(data)
@@ -48,21 +44,20 @@ const processContent = (data, buttonType) => Object.entries(data)
keyClass = '';
}
return isHidden ? null : <Fragment key={key}>
return isHidden ? null : <div key={key}>
<div
className={classNames(`key__${key}`, keyClass, {
'font-weight-bold': isBoolean && value === true,
})}>
className={classNames(`key__${key}`, keyClass, {
'font-weight-bold': isBoolean && value === true,
})}>
<Trans>{isButton ? value : key}</Trans>
</div>
<div className={`value__${key} text-pre text-truncate`}>
<Trans>{(isTitle || isButton || isBoolean) ? '' : value || '—'}</Trans>
</div>
</Fragment>;
</div>;
});
const Logs = (props) => {
const Logs = () => {
const dispatch = useDispatch();
const history = useHistory();
@@ -71,7 +66,14 @@ const Logs = (props) => {
search: search_url_param = '',
} = queryString.parse(history.location.search);
const { filter } = useSelector((state) => state.queryLogs, shallowEqual);
const {
enabled,
processingGetConfig,
processingAdditionalLogs,
processingGetLogs,
} = useSelector((state) => state.queryLogs, shallowEqual);
const filter = useSelector((state) => state.queryLogs.filter, shallowEqual);
const logs = useSelector((state) => state.queryLogs.logs, shallowEqual);
const search = filter?.search || search_url_param;
const response_status = filter?.response_status || response_status_url_param;
@@ -82,6 +84,7 @@ const Logs = (props) => {
const [isModalOpened, setModalOpened] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const closeModal = () => setModalOpened(false);
useEffect(() => {
(async () => {
@@ -94,44 +97,11 @@ const Logs = (props) => {
})();
}, [response_status, search]);
const {
filtering,
setLogsPage,
setLogsPagination,
toggleDetailedLogs,
dashboard,
dnsConfig,
queryLogs: {
enabled,
processingGetConfig,
processingAdditionalLogs,
processingGetLogs,
oldest,
logs,
pages,
page,
isDetailed,
},
} = props;
const mediaQuery = window.matchMedia(`(max-width: ${SMALL_SCREEN_SIZE}px)`);
const mediaQueryHandler = (e) => {
setIsSmallScreen(e.matches);
if (e.matches) {
toggleDetailedLogs(false);
}
};
const closeModal = () => setModalOpened(false);
const getLogs = (older_than, page, initial) => {
if (enabled) {
props.getLogs({
older_than,
page,
pageSize: TABLE_DEFAULT_PAGE_SIZE,
initial,
});
dispatch(toggleDetailedLogs(false));
}
};
@@ -149,7 +119,6 @@ const Logs = (props) => {
(async () => {
setIsLoading(true);
dispatch(setLogsPage(TABLE_FIRST_PAGE));
dispatch(getFilteringStatus());
dispatch(getClients());
try {
@@ -169,6 +138,7 @@ const Logs = (props) => {
mediaQuery.removeEventListener('change', mediaQueryHandler);
} catch (e1) {
try {
// Safari 13.1 do not support mediaQuery.addEventListener('change', handler)
mediaQuery.removeListener(mediaQueryHandler);
} catch (e2) {
console.error(e2);
@@ -179,99 +149,53 @@ const Logs = (props) => {
};
}, []);
const refreshLogs = async () => {
setIsLoading(true);
await Promise.all([
dispatch(setLogsPage(TABLE_FIRST_PAGE)),
dispatch(refreshFilteredLogs()),
]);
dispatch(addSuccessToast('query_log_updated'));
setIsLoading(false);
};
const renderPage = () => <>
<Filters
filter={{
response_status,
search,
}}
setIsLoading={setIsLoading}
processingGetLogs={processingGetLogs}
processingAdditionalLogs={processingAdditionalLogs}
/>
<InfiniteTable
isLoading={isLoading}
items={logs}
isSmallScreen={isSmallScreen}
setDetailedDataCurrent={setDetailedDataCurrent}
setButtonType={setButtonType}
setModalOpened={setModalOpened}
/>
<Modal portalClassName='grid' isOpen={isSmallScreen && isModalOpened}
onRequestClose={closeModal}
style={{
content: {
width: '100%',
height: 'fit-content',
left: 0,
top: 47,
padding: '1rem 1.5rem 1rem',
},
overlay: {
backgroundColor: 'rgba(0,0,0,0.5)',
},
}}
>
<svg
className="icon icon--24 icon-cross d-block d-md-none cursor--pointer"
onClick={closeModal}>
<use xlinkHref="#cross" />
</svg>
{processContent(detailedDataCurrent, buttonType)}
</Modal>
</>;
return (
<>
{enabled && processingGetConfig && <Loading />}
{enabled && !processingGetConfig && (
<>
<Filters
filter={{
response_status,
search,
}}
setIsLoading={setIsLoading}
processingGetLogs={processingGetLogs}
processingAdditionalLogs={processingAdditionalLogs}
refreshLogs={refreshLogs}
/>
<Table
isLoading={isLoading}
setIsLoading={setIsLoading}
logs={logs}
pages={pages}
page={page}
autoClients={dashboard.autoClients}
oldest={oldest}
filtering={filtering}
processingGetLogs={processingGetLogs}
processingGetConfig={processingGetConfig}
isDetailed={isDetailed}
setLogsPagination={setLogsPagination}
setLogsPage={setLogsPage}
toggleDetailedLogs={toggleDetailedLogs}
getLogs={getLogs}
setRules={props.setRules}
addSuccessToast={props.addSuccessToast}
getFilteringStatus={props.getFilteringStatus}
dnssec_enabled={dnsConfig.dnssec_enabled}
setDetailedDataCurrent={setDetailedDataCurrent}
setButtonType={setButtonType}
setModalOpened={setModalOpened}
isSmallScreen={isSmallScreen}
/>
<Modal portalClassName='grid' isOpen={isSmallScreen && isModalOpened}
onRequestClose={closeModal}
style={{
content: {
width: '100%',
height: 'fit-content',
left: 0,
top: 47,
padding: '1rem 1.5rem 1rem',
},
overlay: {
backgroundColor: 'rgba(0,0,0,0.5)',
},
}}
>
<svg
className="icon icon--24 icon-cross d-block d-md-none cursor--pointer"
onClick={closeModal}>
<use xlinkHref="#cross" />
</svg>
{processContent(detailedDataCurrent, buttonType)}
</Modal>
</>
)}
{!enabled && !processingGetConfig && (
<Disabled />
)}
</>
);
};
Logs.propTypes = {
getLogs: PropTypes.func.isRequired,
queryLogs: PropTypes.object.isRequired,
dashboard: PropTypes.object.isRequired,
getFilteringStatus: PropTypes.func.isRequired,
filtering: PropTypes.object.isRequired,
setRules: PropTypes.func.isRequired,
addSuccessToast: PropTypes.func.isRequired,
setLogsPagination: PropTypes.func.isRequired,
setLogsPage: PropTypes.func.isRequired,
toggleDetailedLogs: PropTypes.func.isRequired,
dnsConfig: PropTypes.object.isRequired,
return <>
{enabled && processingGetConfig && <Loading />}
{enabled && !processingGetConfig && renderPage()}
{!enabled && !processingGetConfig && <Disabled />}
</>;
};
export default Logs;