Full rework of the query log

Squashed commit of the following:

commit e8a72eb223551f17e637136713dae03accf8ab9e
Author: Andrey Meshkov <am@adguard.com>
Date:   Thu Jun 18 00:31:53 2020 +0300

    fix race in whois test

commit 801d28197f888fa21f83c9a0b49e3c9472c08513
Merge: 9d9787fd b1c951fb
Author: Andrey Meshkov <am@adguard.com>
Date:   Thu Jun 18 00:28:13 2020 +0300

    Merge branch 'master' into feature/1421

commit 9d9787fd79b17f76c7baed52c12ac462fd00a5e4
Merge: 4ce337ca 08e238ab
Author: Andrey Meshkov <am@adguard.com>
Date:   Thu Jun 18 00:27:32 2020 +0300

    Merge

commit 4ce337ca7aec163edf87a038bb25fb44e64f8613
Author: Andrey Meshkov <am@adguard.com>
Date:   Thu Jun 18 00:22:49 2020 +0300

    -(home): fix whois test

commit 08e238ab0e723b1e354f58245e9a8d5017b392c9
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Thu Jun 18 00:13:41 2020 +0300

    Add comments

commit 5f108065952bcc25dce1c2eee3f9401d2641a6e9
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Wed Jun 17 23:47:50 2020 +0300

    Make tooltip position absolute for touch

commit 4c30a583165e5d007d4b01b657de8751a7bd8c7b
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Wed Jun 17 20:39:44 2020 +0300

    Prevent scroll hide for touch devices

commit 62da97931f5921613762614717c62c77ddb6b8db
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Wed Jun 17 20:06:24 2020 +0300

    Review changes: ipad tooltip

commit 12dddcca8caca51c157b5d25dfa3ca03ba7f0c06
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Wed Jun 17 16:59:16 2020 +0300

    Add close tooltip event for ipad

commit 62191e41d5bf67317f9f1dc6c6af08cbabb4bf90
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Wed Jun 17 16:39:40 2020 +0300

    Add success toast on logs refresh

commit 2ebdd6a8124269d737c8060c3247aaf35d85cb8b
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Wed Jun 17 16:01:37 2020 +0300

    Fix pagination

commit 5820c92bacd93d05a3d66d42ee95f099e1c5d9e9
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Wed Jun 17 11:31:15 2020 +0300

    Revert "Render table in chunks"

    This reverts commit cdfcd849ccddc1bc35591edac7904129431470c9.

commit cdfcd849ccddc1bc35591edac7904129431470c9
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Tue Jun 16 18:42:18 2020 +0300

    Render table in chunks

commit cc8c5e64274bf6e806e2e8a4bf305af745c3ed2a
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Tue Jun 16 17:35:24 2020 +0300

    Add pagination button hover effect

commit f7e134091a1556784a5fea9d83c50353536126ef
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Tue Jun 16 16:28:00 2020 +0300

    Make loader position absolute

commit a7b887b57d903f1f7ac967b861b5cc677728efc4
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Tue Jun 16 15:42:20 2020 +0300

    Ignore clients find without params

commit ecb322fefd4a161d79f28d17fe27827ee91701e4
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Tue Jun 16 15:30:48 2020 +0300

    Styles changes

commit 9323ce3938bf04e1290eade09201ba0790a250c0
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Tue Jun 16 14:32:23 2020 +0300

    Review styles changes

commit e0faa04ba3643f01b2ca99524cdd52b0731725c7
Merge: 98576823 15e71435
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Tue Jun 16 12:08:45 2020 +0300

    Merge branch '1421-new-qlog-v2' into feature/1421

commit 9857682371e8d9a3a91933cfb58a26b3470675d9
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Mon Jun 15 18:32:02 2020 +0300

    Fix response cell

... and 88 more commits
This commit is contained in:
Artem Baskal
2020-06-18 00:36:19 +03:00
parent b1c951fb2c
commit e39fe1b913
81 changed files with 3415 additions and 1087 deletions

View File

@@ -1,4 +1,9 @@
:root {
--yellow-pale: rgba(247, 181, 0, 0.1);
--green79: #67B279;
--gray-a5: #a5a5a5;
--gray-d8: #d8d8d8;
--gray-f3: #F3F3F3;
--font-family-monospace: Monaco, Menlo, "Ubuntu Mono", Consolas, source-code-pro, monospace;
}
@@ -22,6 +27,16 @@ body {
}
}
@media screen and (max-width: 992px) {
.container {
padding: 0 !important;
}
.container--wrap {
min-height: calc(100vh);
}
}
.loading-bar {
position: fixed;
top: 0;
@@ -31,10 +46,17 @@ body {
background: linear-gradient(45deg, rgba(99, 125, 120, 1) 0%, rgba(88, 177, 101, 1) 100%);
}
.hidden {
display: none;
@media (max-width: 575px) {
.container {
padding-right: 0;
padding-left: 0;
}
}
.font-monospace {
font-family: var(--font-family-monospace);
}
.mw-75 {
max-width: 75% !important;
}

View File

@@ -94,7 +94,7 @@ class App extends Component {
)}
<LoadingBar className="loading-bar" updateTime={1000} />
<Route component={Header} />
<div className="container container--wrap">
<div className="container container--wrap pb-5">
{dashboard.processing && <Loading />}
{!dashboard.isCoreRunning && (
<div className="row row-cards">

View File

@@ -32,17 +32,17 @@ const renderBlockingButton = (ipMatchListStatus, ip, handleClick, processing) =>
const buttonProps = ipMatchListStatus === IP_MATCH_LIST_STATUS.NOT_FOUND
? {
className: 'btn-outline-danger',
text: 'block_btn',
text: 'block',
type: 'block',
}
: {
className: 'btn-outline-secondary',
text: 'unblock_btn',
text: 'unblock',
type: 'unblock',
};
return (
<div className="table__action">
<div className="table__action button__action">
<button
type="button"
className={`btn btn-sm ${buttonProps.className}`}

View File

@@ -10,7 +10,7 @@ import BlockedDomains from './BlockedDomains';
import PageTitle from '../ui/PageTitle';
import Loading from '../ui/Loading';
import { ACTION } from '../../helpers/constants';
import { BLOCK_ACTIONS } from '../../helpers/constants';
import './Dashboard.css';
class Dashboard extends Component {
@@ -42,7 +42,7 @@ class Dashboard extends Component {
};
toggleClientStatus = (type, ip) => {
const confirmMessage = type === ACTION.block ? 'client_confirm_block' : 'client_confirm_unblock';
const confirmMessage = type === BLOCK_ACTIONS.BLOCK ? 'client_confirm_block' : 'client_confirm_unblock';
if (window.confirm(this.props.t(confirmMessage, { ip }))) {
this.props.toggleClientBlock(type, ip);

View File

@@ -5,7 +5,7 @@ import { withTranslation } from 'react-i18next';
class Table extends Component {
cellWrap = ({ value }) => (
<div className="logs__row logs__row--overflow">
<div className="logs__row o-hidden">
<span className="logs__text" title={value}>
{value}
</span>
@@ -31,7 +31,7 @@ class Table extends Component {
<div className="logs__row logs__row--center">
<button
type="button"
className="btn btn-icon btn-outline-secondary btn-sm"
className="btn btn-icon btn-icon--green btn-outline-secondary btn-sm"
onClick={() => this.props.handleDelete({
answer: value.row.answer,
domain: value.row.domain,
@@ -59,16 +59,26 @@ class Table extends Component {
columns={this.columns}
loading={processing || processingAdd || processingDelete}
className="-striped -highlight card-table-overflow"
showPagination={true}
showPagination
defaultPageSize={10}
minRows={5}
previousText={t('previous_btn')}
nextText={t('next_btn')}
previousText={
<svg className="icons icon--small icon--gray">
<use xlinkHref="#arrow-left" />
</svg>}
nextText={
<svg className="icons icon--small icon--gray">
<use xlinkHref="#arrow-right" />
</svg>}
loadingText={t('loading_table_status')}
pageText={t('page_table_footer_text')}
ofText="/"
pageText=''
ofText=''
rowsText={t('rows_table_footer_text')}
noDataText={t('rewrite_not_found')}
showPageSizeOptions={false}
showPageJump={false}
renderTotalPagesCount={() => false}
getPaginationProps={() => ({ className: 'custom-pagination' })}
/>
);
}

View File

@@ -48,7 +48,7 @@ class Table extends Component {
accessor: 'url',
minWidth: 200,
Cell: ({ value }) => (
<div className="logs__row logs__row--overflow">
<div className="logs__row o-hidden">
{isValidAbsolutePath(value) ? value
: <a
href={value}
@@ -126,17 +126,26 @@ class Table extends Component {
<ReactTable
data={filters}
columns={this.columns}
showPagination={true}
showPagination
defaultPageSize={10}
showPageSizeOptions={false}
showPageJump={false}
renderTotalPagesCount={() => false}
loading={loading}
minRows={6}
previousText={t('previous_btn')}
nextText={t('next_btn')}
pageText=''
ofText=''
loadingText={t('loading_table_status')}
pageText={t('page_table_footer_text')}
ofText="/"
rowsText={t('rows_table_footer_text')}
noDataText={whitelist ? t('no_whitelist_added') : t('no_blocklist_added')}
getPaginationProps={() => ({ className: 'custom-pagination' })}
previousText={
<svg className="icons icon--small icon--gray w-100 h-100">
<use xlinkHref="#arrow-left" />
</svg>}
nextText={
<svg className="icons icon--small icon--gray w-100 h-100">
<use xlinkHref="#arrow-right" />
</svg>}
/>
);
}

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { nanoid } from 'nanoid';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { formatClientCell } from '../../../helpers/formatClientCell';
import getHintElement from './getHintElement';
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 },
} = row.original;
const autoClient = autoClients.find((autoClient) => autoClient.name === client);
const country = autoClient && autoClient.whois_info && autoClient.whois_info.country;
const city = autoClient && autoClient.whois_info && autoClient.whois_info.city;
const network = autoClient && autoClient.whois_info && autoClient.whois_info.orgname;
const source = autoClient && autoClient.source;
const id = nanoid();
const data = {
address: client,
name,
country,
city,
network,
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,
'white-space--nowrap': isDetailed,
});
const hintClass = classNames('icons mr-4 icon--small cursor--pointer icon--light-gray', {
'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">
{processedData && getHintElement({
className: hintClass,
columnClass: 'grid grid--limited',
tooltipClass: 'px-5 pb-5 pt-4 mw-75',
dataTip: true,
xlinkHref: 'question',
contentItemClass: 'text-truncate key-colon',
title: 'client_details',
content: processedData,
place: 'bottom',
})}
<div
className={nameClass}>
<div data-tip={true} data-for={id}>{formatClientCell(row, t, isDetailed)}</div>
{isDetailed && name
&& <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

@@ -0,0 +1,28 @@
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

@@ -0,0 +1,121 @@
import React from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import getHintElement from './getHintElement';
import {
DEFAULT_SHORT_DATE_FORMAT_OPTIONS,
LONG_TIME_FORMAT,
SCHEME_TO_PROTOCOL_MAP,
} from '../../../helpers/constants';
import { formatDateTime, formatTime } from '../../../helpers/helpers';
const getDomainCell = (props) => {
const {
row, t, isDetailed, dnssec_enabled,
} = props;
const {
tracker, type, answer_dnssec, client_proto, domain, time,
} = row.original;
const hasTracker = !!tracker;
const source = tracker && tracker.sourceData && tracker.sourceData.name;
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', {
'icon--active': hasTracker,
'icon--disabled': !hasTracker,
'my-3': isDetailed,
});
const dnssecHint = getHintElement({
className: lockIconClass,
tooltipClass: 'py-4 px-5 pb-45',
dataTip: answer_dnssec,
xlinkHref: 'lock',
columnClass: 'w-100',
content: 'validated_with_dnssec',
place: 'bottom',
});
const protocol = t(SCHEME_TO_PROTOCOL_MAP[client_proto]) || '';
const ip = type ? `${t('type_table_header')}: ${type}` : '';
const requestDetailsObj = {
time_table_header: formatTime(time, LONG_TIME_FORMAT),
date: formatDateTime(time, DEFAULT_SHORT_DATE_FORMAT_OPTIONS),
domain,
type_table_header: type,
protocol,
};
const knownTrackerDataObj = {
name_table_header: tracker && tracker.name,
category_label: tracker && tracker.category,
source_label: source && <a href={`//${source}`} className="link--green">{source}</a>,
};
const renderGrid = (content, idx) => {
const preparedContent = typeof content === 'string' ? t(content) : content;
const className = classNames('text-truncate key-colon o-hidden', {
'word-break--break-all white-space--normal': preparedContent.length > 100,
});
return <div key={idx} className={className}>{preparedContent}</div>;
};
const getGrid = (contentObj, title, className) => [
<div key={title} className={classNames('pb-2 grid--title', className)}>{t(title)}</div>,
<div key={`${title}-1`} className="grid grid--limited">{React.Children.map(Object.entries(contentObj), renderGrid)}</div>,
];
const requestDetails = getGrid(requestDetailsObj, 'request_details');
const renderContent = hasTracker ? requestDetails.concat(getGrid(knownTrackerDataObj, 'known_tracker', 'pt-4')) : requestDetails;
const trackerHint = getHintElement({
className: privacyIconClass,
tooltipClass: 'pt-4 pb-5 px-5 mw-75',
dataTip: true,
xlinkHref: 'privacy',
contentItemClass: 'key-colon',
renderContent,
place: 'bottom',
});
const valueClass = classNames('w-100', {
'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 && dnssecHint}
{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>
</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,
};
export default getDomainCell;

View File

@@ -0,0 +1,76 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import CustomTooltip from '../Tooltip/CustomTooltip';
const getHintElement = ({
className,
contentItemClass,
columnClass,
dataTip,
xlinkHref,
content,
title,
place,
tooltipClass,
trigger,
overridePosition,
scrollHide,
renderContent,
}) => {
const id = 'id';
const [isHovered, hover] = useState(false);
const openTooltip = () => hover(true);
const closeTooltip = () => hover(false);
return <div onMouseEnter={openTooltip}
onMouseLeave={closeTooltip}>
<div data-tip={dataTip}
data-for={dataTip ? id : undefined}
data-event={trigger}
>
{xlinkHref && <svg className={className}>
<use xlinkHref={`#${xlinkHref}`} />
</svg>}
</div>
{isHovered && dataTip
&& <CustomTooltip
className={tooltipClass}
id={id}
columnClass={columnClass}
contentItemClass={contentItemClass}
title={title}
place={place}
content={content}
trigger={trigger}
overridePosition={overridePosition}
scrollHide={scrollHide}
renderContent={renderContent}
/>}
</div>;
};
getHintElement.propTypes = {
className: PropTypes.string,
contentItemClass: PropTypes.string,
columnClass: PropTypes.string,
tooltipClass: PropTypes.string,
title: PropTypes.string,
place: PropTypes.string,
dataTip: PropTypes.string,
xlinkHref: PropTypes.string,
overridePosition: PropTypes.func,
scrollHide: PropTypes.bool,
trigger: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
]),
content: PropTypes.oneOfType([
PropTypes.string,
PropTypes.array,
]),
renderContent: PropTypes.arrayOf(PropTypes.element),
};
export default getHintElement;

View File

@@ -0,0 +1,120 @@
import React from 'react';
import classNames from 'classnames';
import { formatElapsedMs } from '../../../helpers/helpers';
import {
CUSTOM_FILTERING_RULES_ID,
FILTERED_STATUS,
FILTERED_STATUS_TO_META_MAP,
} from '../../../helpers/constants';
import getHintElement from './getHintElement';
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 getResponseCell = (row, filtering, t, isDetailed) => {
const {
reason, filterId, rule, status, upstream, elapsedMs, domain, response,
} = row.original;
const { filters, whitelistFilters } = filtering;
const formattedElapsedMs = formatElapsedMs(elapsedMs, t);
const statusLabel = t((FILTERED_STATUS_TO_META_MAP[reason]
&& 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', {
'white-space--normal': response.length > 100,
});
return <div key={response} className={className}>{`${response}\n`}</div>;
})}</div>;
};
const FILTERED_STATUS_TO_FIELDS_MAP = {
[FILTERED_STATUS.NOT_FILTERED_NOT_FOUND]: {
domain,
encryption_status: boldStatusLabel,
install_settings_dns: upstream,
elapsed: formattedElapsedMs,
response_code: status,
response_table_header: renderResponses(response),
},
[FILTERED_STATUS.FILTERED_BLOCKED_SERVICE]: {
domain,
encryption_status: boldStatusLabel,
filter,
rule_label: rule,
response_code: status,
},
[FILTERED_STATUS.FILTERED_SAFE_SEARCH]: {
domain,
encryption_status: boldStatusLabel,
install_settings_dns: upstream,
elapsed: formattedElapsedMs,
response_code: status,
},
[FILTERED_STATUS.FILTERED_BLACK_LIST]: {
domain,
encryption_status: boldStatusLabel,
install_settings_dns: upstream,
elapsed: formattedElapsedMs,
response_code: status,
},
};
const fields = FILTERED_STATUS_TO_FIELDS_MAP[reason]
? Object.entries(FILTERED_STATUS_TO_FIELDS_MAP[reason])
: Object.entries(FILTERED_STATUS_TO_FIELDS_MAP.NotFilteredNotFound);
const detailedInfo = reason === FILTERED_STATUS.FILTERED_BLOCKED_SERVICE
|| reason === FILTERED_STATUS.FILTERED_BLACK_LIST
? filter : formattedElapsedMs;
return (
<div className="logs__row">
{fields && getHintElement({
className: classNames('icons mr-4 icon--small cursor--pointer icon--light-gray', { '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',
dataTip: true,
xlinkHref: 'question',
title: 'response_details',
content: fields,
place: '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,30 @@
import React, { Fragment } from 'react';
import { Trans } from 'react-i18next';
import { HashLink as Link } from 'react-router-hash-link';
import Card from '../ui/Card';
const Disabled = () => (
<Fragment>
<div className="page-header">
<h1 className="page-title page-title--large">
<Trans>query_log</Trans>
</h1>
</div>
<Card>
<div className="lead text-center py-6">
<Trans
components={[
<Link to="/settings#logs-config" key="0">
link
</Link>,
]}
>
query_log_disabled
</Trans>
</div>
</Card>
</Fragment>
);
export default Disabled;

View File

@@ -1,11 +1,9 @@
import React, { Fragment } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { Field, reduxForm } from 'redux-form';
import { withTranslation } from 'react-i18next';
import flow from 'lodash/flow';
import { renderInputField } from '../../../helpers/form';
import { FORM_NAME, RESPONSE_FILTER } from '../../../helpers/constants';
import { useTranslation } from 'react-i18next';
import debounce from 'lodash/debounce';
import { DEBOUNCE_FILTER_TIMEOUT, FORM_NAME, RESPONSE_FILTER } from '../../../helpers/constants';
import Tooltip from '../../ui/Tooltip';
const renderFilterField = ({
@@ -18,25 +16,28 @@ const renderFilterField = ({
autoComplete,
tooltip,
meta: { touched, error },
}) => <Fragment>
<div className="logs__input-wrap">
<input
{...input}
id={id}
placeholder={placeholder}
type={type}
className={className}
disabled={disabled}
autoComplete={autoComplete}
/>
<span className="logs__notice">
}) => <>
<div className="input-group-search">
<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} />
<span className="logs__notice">
<Tooltip text={tooltip} type='tooltip-custom--logs' />
</span>
{!disabled
&& touched
&& (error && <span className="form__message form__message--error">{error}</span>)}
</div>
</Fragment>;
{!disabled
&& touched
&& (error && <span className="form__message form__message--error">{error}</span>)}
</>;
renderFilterField.propTypes = {
input: PropTypes.object.isRequired,
@@ -55,62 +56,47 @@ renderFilterField.propTypes = {
const Form = (props) => {
const {
t,
handleChange,
className = '',
responseStatusClass,
submit,
} = props;
const [t] = useTranslation();
const debouncedSubmit = debounce(submit, DEBOUNCE_FILTER_TIMEOUT);
const zeroDelaySubmit = () => setTimeout(submit, 0);
return (
<form onSubmit={handleChange}>
<div className="row">
<div className="col-6 col-sm-3 my-2">
<Field
id="filter_domain"
name="filter_domain"
component={renderFilterField}
type="text"
className="form-control"
placeholder={t('domain_name_table_header')}
tooltip={t('query_log_strict_search')}
onChange={handleChange}
/>
</div>
<div className="col-6 col-sm-3 my-2">
<Field
id="filter_question_type"
name="filter_question_type"
component={renderInputField}
type="text"
className="form-control"
placeholder={t('type_table_header')}
onChange={handleChange}
/>
</div>
<div className="col-6 col-sm-3 my-2">
<Field
name="filter_response_status"
component="select"
className="form-control custom-select"
>
<option value={RESPONSE_FILTER.ALL}>
{t('show_all_filter_type')}
</option>
<option value={RESPONSE_FILTER.FILTERED}>
{t('show_filtered_type')}
</option>
</Field>
</div>
<div className="col-6 col-sm-3 my-2">
<Field
id="filter_client"
name="filter_client"
component={renderFilterField}
type="text"
className="form-control"
placeholder={t('client_table_header')}
tooltip={t('query_log_strict_search')}
onChange={handleChange}
/>
</div>
<form className="d-flex flex-wrap form-control--container"
onSubmit={(e) => {
e.preventDefault();
zeroDelaySubmit();
debouncedSubmit.cancel();
}}
>
<Field
id="search"
name="search"
component={renderFilterField}
type="text"
className={`form-control--search form-control--transparent ${className}`}
placeholder={t('domain_or_client')}
tooltip={t('query_log_strict_search')}
onChange={debouncedSubmit}
/>
<div className="field__select">
<Field
name="response_status"
component="select"
className={`form-control custom-select custom-select--logs custom-select__arrow--left ml-small form-control--transparent ${responseStatusClass}`}
onChange={zeroDelaySubmit}
>
{Object.values(RESPONSE_FILTER)
.map(({
query, label, disabled,
}) => <option key={label} value={query}
disabled={disabled}>{t(label)}</option>)}
</Field>
</div>
</form>
);
@@ -118,12 +104,11 @@ const Form = (props) => {
Form.propTypes = {
handleChange: PropTypes.func,
t: PropTypes.func.isRequired,
className: PropTypes.string,
responseStatusClass: PropTypes.string,
submit: PropTypes.func.isRequired,
};
export default flow([
withTranslation(),
reduxForm({
form: FORM_NAME.LOGS_FILTER,
}),
])(Form);
export default reduxForm({
form: FORM_NAME.LOGS_FILTER,
})(Form);

View File

@@ -1,52 +1,48 @@
import React, { Component } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import debounce from 'lodash/debounce';
import classnames from 'classnames';
import { DEBOUNCE_FILTER_TIMEOUT, RESPONSE_FILTER } from '../../../helpers/constants';
import { isValidQuestionType } from '../../../helpers/helpers';
import { Trans } from 'react-i18next';
import { useDispatch } from 'react-redux';
import Form from './Form';
import Card from '../../ui/Card';
import { setLogsFilter } from '../../../actions/queryLogs';
class Filters extends Component {
getFilters = ({
filter_domain, filter_question_type, filter_response_status, filter_client,
}) => ({
filter_domain: filter_domain || '',
filter_question_type: isValidQuestionType(filter_question_type) ? filter_question_type.toUpperCase() : '',
filter_response_status: filter_response_status === RESPONSE_FILTER.FILTERED ? filter_response_status : '',
filter_client: filter_client || '',
});
const Filters = ({ filter, refreshLogs, setIsLoading }) => {
const dispatch = useDispatch();
handleFormChange = debounce((values) => {
const filter = this.getFilters(values);
this.props.setLogsFilter(filter);
}, DEBOUNCE_FILTER_TIMEOUT);
const onSubmit = async (values) => {
setIsLoading(true);
await dispatch(setLogsFilter(values));
setIsLoading(false);
};
render() {
const { filter, processingAdditionalLogs } = this.props;
return (
<div className="page-header page-header--logs">
<h1 className="page-title page-title--large">
<Trans>query_log</Trans>
<button
type="button"
className="btn btn-icon--green ml-3 bg-transparent"
onClick={refreshLogs}
>
<svg className="icons icon--small">
<use xlinkHref="#update" />
</svg>
</button>
const cardBodyClass = classnames({
'card-body': true,
'card-body--loading': processingAdditionalLogs,
});
return (
<Card bodyType={cardBodyClass}>
<Form
initialValues={filter}
onChange={this.handleFormChange}
/>
</Card>
);
}
}
</h1>
<Form
responseStatusClass="d-sm-block"
initialValues={filter}
onSubmit={onSubmit}
/>
</div>
);
};
Filters.propTypes = {
filter: PropTypes.object.isRequired,
setLogsFilter: PropTypes.func.isRequired,
refreshLogs: PropTypes.func.isRequired,
processingGetLogs: PropTypes.bool.isRequired,
processingAdditionalLogs: PropTypes.bool.isRequired,
setIsLoading: PropTypes.func.isRequired,
};
export default Filters;

View File

@@ -1,8 +1,15 @@
:root {
--gray-4d: #4D4D4D;
--gray-8: #888;
--danger: #DF3812;
}
.logs__row {
position: relative;
display: flex;
align-items: center;
min-height: 26px;
overflow: hidden;
text-overflow: ellipsis;
}
.logs__row--center {
@@ -15,10 +22,6 @@
justify-content: center;
}
.logs__row--overflow {
overflow: hidden;
}
.logs__row--icons {
max-width: 180px;
flex-flow: row wrap;
@@ -35,6 +38,15 @@
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
font-size: 1rem;
font-family: var(--font-family-sans-serif);
color: var(--gray-4d);
letter-spacing: 0;
line-height: 1.5rem;
}
.logs__text--bold {
font-weight: bold;
}
.logs__text--full {
@@ -51,6 +63,11 @@
white-space: normal;
}
.logs__text--nowrap {
line-height: 1.4;
white-space: nowrap;
}
.logs__text--whois {
line-height: 1.2;
}
@@ -61,11 +78,18 @@
margin-right: 5px;
}
.logs__action,
.table__action {
position: absolute;
top: 11px;
right: 15px;
.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;
@@ -73,11 +97,31 @@
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;
@@ -152,14 +196,14 @@
}
.logs__notice {
position: absolute;
position: relative;
z-index: 1;
top: 8px;
right: 10px;
margin-top: 3px;
font-size: 12px;
top: 0.5rem;
right: 2rem;
margin-top: 0.1875rem;
font-size: 0.75rem;
text-align: left;
color: #a5a5a5;
color: var(--gray-a5);
}
.logs__whois {
@@ -185,3 +229,348 @@
margin-right: 1px;
opacity: 0.5;
}
/* New logs */
.logs__table {
background-color: #fff;
border: 0;
border-radius: 8px;
min-height: 42rem;
max-width: 71rem;
}
.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;
}
.detailed-info {
font-size: 0.8rem;
line-height: 1.4;
color: #888888;
}
.icon--selected {
background-color: var(--gray-f3);
border: solid 1px var(--gray-d8);
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;
}
.-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;
}
.pagination-bottom {
justify-content: center !important;
display: flex !important;
}
.-center:before {
content: '...';
transform: translateY(-0.25rem);
margin: auto;
}
.-center:after {
content: '...';
transform: translateY(-0.25rem);
margin: auto;
}
.icon--detailed-info {
position: absolute;
right: 0;
top: 0.5rem;
}
.icon--light-gray {
color: var(--gray-8);
}
.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;
}
.cursor--pointer {
cursor: pointer;
}
.custom-select__arrow--left {
background: #fff url('./chevron-down.svg') no-repeat left 0.2rem center;
background-size: 1.5rem;
}
.custom-select--logs {
padding: 0.5rem 0.75rem 0.5rem 1.75rem !important;
}
.bg--danger {
color: var(--danger);
}
.ml-small {
margin-left: 3.3125rem;
}
.form-control--search {
width: 39.125rem;
box-shadow: 0 1px 0 #ddd;
padding: 0 2.5rem;
height: 2.25rem;
}
.form-control--transparent {
border: 0 solid transparent !important;
background-color: transparent !important;
}
.input-group-search {
background-color: transparent;
position: relative;
left: 2rem;
top: 0.4rem;
width: 1.5rem;
height: 1.5rem;
}
.form-control--container {
max-width: 100%;
}
@media (max-width: 1279.98px) {
.form-control--search {
max-width: 30.125rem;
}
.form-control--container {
max-width: 70%;
}
.form-control--search {
max-width: 50%;
}
}
@media (max-width: 991.98px) {
.form-control--search {
max-width: 40%;
}
.form-control--container {
max-width: 100%;
}
}
@media (max-width: 767.98px) {
.rt-tr .logs__row .logs__text {
max-width: calc(100% - 1.5rem);
}
.ml-small {
margin-left: 1.5rem;
}
}
@media (max-width: 575px) {
.logs__table .rt-tr {
height: 3.125rem;
}
.logs__table .rt-tbody .rt-td {
padding: 0.625rem 1rem 0.875rem 0;
}
.logs__table {
min-height: 42rem;
}
}
@media (max-width: 500px) {
.form-control--search {
max-width: 85%;
}
.field__select {
margin-top: 1.5rem;
}
}
.loading__container > .-loading-inner {
top: 10rem !important;
bottom: initial !important;
}
.loading__text {
transform: translateY(3rem);
}
/*reset position to make absolute position of tooltip on tablets, may cause problems https://github.com/wwayne/react-tooltip/issues/204*/
@media (hover: none) {
.logs__action {
top: 1rem !important;
right: 1rem;
}
.logs__table .rt-td,
.clients__table .rt-td {
position: initial;
}
.logs__row {
position: initial;
}
}

View File

@@ -0,0 +1,399 @@
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,
} from '../../helpers/constants';
import getDateCell from './Cells/getDateCell';
import getDomainCell from './Cells/getDomainCell';
import getClientCell from './Cells/getClientCell';
import getResponseCell from './Cells/getResponseCell';
import {
checkFiltered,
formatDateTime,
formatElapsedMs,
formatTime,
} from '../../helpers/helpers';
import Loading from '../ui/Loading';
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 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,
),
minWidth: 150,
maxHeight: 60,
headerClassName: 'logs__text',
},
{
Header: () => {
const plainSelected = classNames('cursor--pointer', {
'icon--selected': !isDetailed,
});
const detailedSelected = classNames('cursor--pointer', {
'icon--selected': isDetailed,
});
return <div className="d-flex justify-content-between">
{t('client_table_header')}
{<span>
<svg
className={`icons icon--small icon--active mr-2 cursor--pointer ${plainSelected}`}
onClick={() => toggleDetailedLogs(false)}
>
<title><Trans>compact</Trans></title>
<use xlinkHref='#list' />
</svg>
<svg
className={`icons icon--small icon--active cursor--pointer ${detailedSelected}`}
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',
},
];
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}
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--small icon--gray w-100 h-100 cursor--pointer">
<title><Trans>previous_btn</Trans></title>
<use xlinkHref="#arrow-left" />
</svg>}
nextText={
<svg className="icons icon--small 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,
} = rowInfo.original;
const hasTracker = !!tracker;
const autoClient = autoClients.find(
(autoClient) => autoClient.name === client,
);
const country = autoClient && autoClient.whois_info
&& autoClient.whois_info.country;
const network = autoClient && autoClient.whois_info
&& autoClient.whois_info.orgname;
const city = autoClient && autoClient.whois_info
&& autoClient.whois_info.city;
const source = autoClient && autoClient.source;
const formattedElapsedMs = formatElapsedMs(elapsedMs, t);
const isFiltered = checkFiltered(reason);
const buttonType = isFiltered ? BLOCK_ACTIONS.UNBLOCK : BLOCK_ACTIONS.BLOCK;
const onToggleBlock = () => {
toggleBlocking(buttonType, domain);
};
const tracker_source = tracker && tracker.sourceData
&& tracker.sourceData.name;
const status = t((FILTERED_STATUS_TO_META_MAP[reason]
&& FILTERED_STATUS_TO_META_MAP[reason].label) || reason);
const statusBlocked = <div className="bg--danger">{status}</div>;
const protocol = t(SCHEME_TO_PROTOCOL_MAP[client_proto]) || '';
const detailedData = {
time_table_header: formatTime(time, LONG_TIME_FORMAT),
date: formatDateTime(time, DEFAULT_SHORT_DATE_FORMAT_OPTIONS),
encryption_status: status,
domain,
type_table_header: type,
protocol,
known_tracker: hasTracker && 'title',
table_name: hasTracker && tracker.name,
category_label: hasTracker && tracker.category,
tracker_source: hasTracker && tracker_source && <a href={`//${source}`}
className="link--green">{tracker_source}</a>,
response_details: 'title',
install_settings_dns: upstream,
elapsed: formattedElapsedMs,
response_table_header: response && response.join('\n'),
client_details: 'title',
ip_address: client,
name: info && info.name,
country,
city,
network,
source_label: source,
validated_with_dnssec: dnssec_enabled ? Boolean(answer_dnssec) : false,
[buttonType]: <div onClick={onToggleBlock}
className="title--border bg--danger">{t(buttonType)}</div>,
};
const detailedDataBlocked = {
time_table_header: formatTime(time, LONG_TIME_FORMAT),
date: formatDateTime(time, DEFAULT_SHORT_DATE_FORMAT_OPTIONS),
encryption_status: statusBlocked,
domain,
type_table_header: type,
protocol,
known_tracker: 'title',
table_name: hasTracker && tracker.name,
category_label: hasTracker && tracker.category,
source_label: hasTracker && source
&& <a href={`//${source}`} className="link--green">{source}</a>,
response_details: 'title',
install_settings_dns: upstream,
elapsed: formattedElapsedMs,
response_table_header: response && response.join('\n'),
[buttonType]: <div onClick={onToggleBlock}
className="title--border">{t(buttonType)}</div>,
};
const detailedDataCurrent = isFiltered ? detailedDataBlocked : detailedData;
setDetailedDataCurrent(detailedDataCurrent);
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

@@ -0,0 +1,44 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Trans } from 'react-i18next';
import classNames from 'classnames';
import Tooltip from './index';
const CustomTooltip = ({
id, title, className, contentItemClass, place = 'right', columnClass = '', content, trigger, overridePosition, scrollHide,
renderContent = React.Children.map(
content,
(item, idx) => <div key={idx} className={contentItemClass}>
<Trans>{item || '—'}</Trans>
</div>,
),
}) => <Tooltip id={id} className={className} place={place} trigger={trigger}
overridePosition={overridePosition}
scrollHide={scrollHide}
>
{title
&& <div className="pb-4 h-25 grid-content font-weight-bold"><Trans>{title}</Trans></div>}
<div className={classNames(columnClass)}>{renderContent}</div>
</Tooltip>;
CustomTooltip.propTypes = {
id: PropTypes.string.isRequired,
title: PropTypes.string,
place: PropTypes.string,
className: PropTypes.string,
columnClass: PropTypes.string,
contentItemClass: PropTypes.string,
overridePosition: PropTypes.func,
scrollHide: PropTypes.bool,
content: PropTypes.oneOfType([
PropTypes.string,
PropTypes.array,
]),
trigger: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
]),
renderContent: PropTypes.arrayOf(PropTypes.element),
};
export default CustomTooltip;

View File

@@ -0,0 +1,129 @@
.custom-tooltip {
padding: 1rem 1.5rem 1.25rem 1.5rem;
font-size: 16px !important;
box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.2);
border-radius: 4px !important;
pointer-events: auto !important;
}
/*crutch, may cause problems https://github.com/wwayne/react-tooltip/issues/204*/
@media (hover: none) {
.custom-tooltip {
position: absolute !important;
top: 4rem !important;
}
}
.white-space--nowrap {
white-space: nowrap !important;
}
.white-space--normal {
white-space: normal !important;
}
.word-break--break-all {
word-break: break-all !important;
}
.grid {
display: grid;
grid-template-columns: repeat(2, min-content);
grid-row-gap: 0.5rem;
grid-column-gap: 1rem;
}
.grid--limited {
grid-template-columns: repeat(2, minmax(0, min-content));
}
.grid--gap-bg {
grid-column-gap: 1.5rem;
}
.grid--title {
font-weight: bold;
}
.grid--title:not(:first-child) {
padding-top: 1rem;
}
@media (max-width: 767.98px) {
.grid {
grid-template-columns: 35% 55%;
}
.grid * {
grid-column: 1 / -1;
}
.grid > :nth-child(even) {
margin: -0.5rem 0 0;
}
.grid > .key__time_table_header, .grid > .key__data, .grid > .key__encryption_status, .grid > .key__elapsed {
grid-column: 1 / span 1;
}
.grid > .value__time_table_header, .grid > .value__data, .grid > .value__encryption_status, .grid > .value__elapsed {
grid-column: 2 / span 1;
margin: 0 !important;
}
.grid .key-colon, .grid .title--border {
font-weight: bold;
}
.custom-tooltip {
overflow-y: scroll;
}
}
.grid .key-colon:nth-child(odd)::after {
content: ':';
}
.grid__one-row {
grid-template-columns: 15rem;
}
.grid__flow-column {
grid-auto-flow: column;
}
.custom-tooltip.show {
opacity: 1 !important;
}
.custom-tooltip:hover {
opacity: 1 !important;
}
.grid-content > * {
justify-content: space-between !important;
width: 100% !important;
overflow: hidden;
-o-text-overflow: ellipsis;
text-overflow: ellipsis;
white-space: nowrap;
}
.title--border {
padding-top: 2rem;
}
.title--border:before {
content: '';
position: absolute;
left: 0;
border-top: 0.5px solid var(--gray-d8) !important;
width: 100%;
margin-top: -1rem;
}
.icon-cross {
position: absolute;
right: 0.5rem;
top: 0.5rem;
}

View File

@@ -0,0 +1,48 @@
import React from 'react';
import PropTypes from 'prop-types';
import ReactTooltip from 'react-tooltip';
import classNames from 'classnames';
import './ReactTooltip.css';
import { touchMediaQuery } from '../../../helpers/constants';
const Tooltip = ({
id, children, className = '', place = 'right', trigger = 'hover', overridePosition, scrollHide = true,
}) => {
const tooltipClassName = classNames('custom-tooltip', className);
return (
<ReactTooltip
id={id}
aria-haspopup="true"
effect="solid"
place={place}
className={tooltipClassName}
backgroundColor="#fff"
arrowColor="transparent"
textColor="#4d4d4d"
delayHide={300}
scrollHide={window.matchMedia(touchMediaQuery).matches ? false : scrollHide}
trigger={trigger}
overridePosition={overridePosition}
globalEventOff="click touchend"
clickable
>
{children}
</ReactTooltip>
);
};
Tooltip.propTypes = {
id: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
className: PropTypes.string,
place: PropTypes.string,
overridePosition: PropTypes.func,
scrollHide: PropTypes.bool,
trigger: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
]),
};
export default Tooltip;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#9aa0ac" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-down"><polyline points="6 9 12 15 18 9"></polyline></svg>

After

Width:  |  Height:  |  Size: 264 B

View File

@@ -1,457 +1,210 @@
import React, { Component, Fragment } from 'react';
import React, { Fragment, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import ReactTable from 'react-table';
import escapeRegExp from 'lodash/escapeRegExp';
import endsWith from 'lodash/endsWith';
import { Trans, withTranslation } from 'react-i18next';
import { HashLink as Link } from 'react-router-hash-link';
import { Trans } from 'react-i18next';
import Modal from 'react-modal';
import { useDispatch } from 'react-redux';
import {
formatTime,
formatDateTime,
isToday,
checkFiltered,
checkRewrite,
checkRewriteHosts,
checkWhiteList,
checkBlackList,
checkBlockedService,
} from '../../helpers/helpers';
import {
SERVICES, TABLE_DEFAULT_PAGE_SIZE, CUSTOM_FILTERING_RULES_ID, FILTERED,
BLOCK_ACTIONS, smallScreenSize,
TABLE_DEFAULT_PAGE_SIZE,
TABLE_FIRST_PAGE,
} from '../../helpers/constants';
import { getTrackerData } from '../../helpers/trackers/trackers';
import { formatClientCell } from '../../helpers/formatClientCell';
import Filters from './Filters';
import PageTitle from '../ui/PageTitle';
import Card from '../ui/Card';
import Loading from '../ui/Loading';
import PopoverFiltered from '../ui/PopoverFilter';
import Popover from '../ui/Popover';
import Filters from './Filters';
import Table from './Table';
import Disabled from './Disabled';
import './Logs.css';
import CellWrap from '../ui/CellWrap';
import { getFilteringStatus } from '../../actions/filtering';
import { getClients } from '../../actions';
import { getDnsConfig } from '../../actions/dnsConfig';
import { getLogsConfig } from '../../actions/queryLogs';
import { addSuccessToast } from '../../actions/toasts';
const TABLE_FIRST_PAGE = 0;
const INITIAL_REQUEST = true;
const INITIAL_REQUEST_DATA = ['', TABLE_FIRST_PAGE, INITIAL_REQUEST];
class Logs extends Component {
componentDidMount() {
this.props.setLogsPage(TABLE_FIRST_PAGE);
this.getLogs(...INITIAL_REQUEST_DATA);
this.props.getFilteringStatus();
this.props.getLogsConfig();
}
export const processContent = (data, buttonType) => Object.entries(data)
.map(([key, value]) => {
const isTitle = value === 'title';
const isButton = key === buttonType;
const isBoolean = typeof value === 'boolean';
const isHidden = isBoolean && value === false;
getLogs = (older_than, page, initial) => {
if (this.props.queryLogs.enabled) {
this.props.getLogs({
older_than, page, pageSize: TABLE_DEFAULT_PAGE_SIZE, initial,
let keyClass = 'key-colon';
if (isTitle) {
keyClass = 'title--border';
}
if (isButton || isBoolean) {
keyClass = '';
}
return isHidden ? null : <Fragment key={key}>
<div
className={`key__${key} ${keyClass} ${(isBoolean && value === true) ? 'font-weight-bold' : ''}`}>
<Trans>{isButton ? value : key}</Trans>
</div>
<div className={`value__${key} text-pre text-truncate`}>
<Trans>{(isTitle || isButton || isBoolean) ? '' : value || '—'}</Trans>
</div>
</Fragment>;
});
const Logs = (props) => {
const dispatch = useDispatch();
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);
const {
filtering,
setLogsPage,
setLogsPagination,
setLogsFilter,
toggleDetailedLogs,
dashboard,
dnsConfig,
queryLogs: {
filter,
enabled,
processingGetConfig,
processingAdditionalLogs,
processingGetLogs,
oldest,
logs,
pages,
page,
isDetailed,
},
} = props;
const mediaQuery = window.matchMedia(`(max-width: ${smallScreenSize}px)`);
const mediaQueryHandler = (e) => {
setIsSmallScreen(e.matches);
if (e.matches) {
toggleDetailedLogs(false);
}
};
useEffect(() => {
mediaQuery.addListener(mediaQueryHandler);
return () => mediaQuery.removeListener(mediaQueryHandler);
}, []);
const closeModal = () => setModalOpened(false);
const getLogs = (older_than, page, initial) => {
if (props.queryLogs.enabled) {
props.getLogs({
older_than,
page,
pageSize: TABLE_DEFAULT_PAGE_SIZE,
initial,
});
}
};
refreshLogs = () => {
window.location.reload();
useEffect(() => {
(async () => {
setIsLoading(true);
dispatch(setLogsPage(TABLE_FIRST_PAGE));
dispatch(getFilteringStatus());
dispatch(getClients());
try {
await Promise.all([
getLogs(...INITIAL_REQUEST_DATA),
dispatch(getLogsConfig()),
dispatch(getDnsConfig()),
]);
} catch (err) {
console.error(err);
} finally {
setIsLoading(false);
}
})();
}, []);
const refreshLogs = async () => {
setIsLoading(true);
await Promise.all([
dispatch(setLogsPage(TABLE_FIRST_PAGE)),
getLogs(...INITIAL_REQUEST_DATA),
]);
dispatch(addSuccessToast('query_log_updated'));
setIsLoading(false);
};
renderTooltip = (isFiltered, rule, filter, service) => isFiltered
&& <PopoverFiltered rule={rule} filter={filter} service={service} />;
renderResponseList = (response, status) => {
if (response.length > 0) {
const listItems = response.map((response, index) => (
<li key={index} title={response} className="logs__list-item">
{response}
</li>
));
return <ul className="list-unstyled">{listItems}</ul>;
}
return (
<div>
<Trans values={{ value: status }}>query_log_response_status</Trans>
</div>
);
};
toggleBlocking = (type, domain) => {
const { userRules } = this.props.filtering;
const { t } = this.props;
const lineEnding = !endsWith(userRules, '\n') ? '\n' : '';
const baseRule = `||${domain}^$important`;
const baseUnblocking = `@@${baseRule}`;
const blockingRule = type === 'block' ? baseUnblocking : baseRule;
const unblockingRule = type === 'block' ? baseRule : baseUnblocking;
const preparedBlockingRule = new RegExp(`(^|\n)${escapeRegExp(blockingRule)}($|\n)`);
const preparedUnblockingRule = new RegExp(`(^|\n)${escapeRegExp(unblockingRule)}($|\n)`);
if (userRules.match(preparedBlockingRule)) {
this.props.setRules(userRules.replace(`${blockingRule}`, ''));
this.props.addSuccessToast(`${t('rule_removed_from_custom_filtering_toast')}: ${blockingRule}`);
} else if (!userRules.match(preparedUnblockingRule)) {
this.props.setRules(`${userRules}${lineEnding}${unblockingRule}\n`);
this.props.addSuccessToast(`${t('rule_added_to_custom_filtering_toast')}: ${unblockingRule}`);
}
this.props.getFilteringStatus();
};
renderBlockingButton(isFiltered, domain) {
const buttonClass = isFiltered ? 'btn-outline-secondary' : 'btn-outline-danger';
const buttonText = isFiltered ? 'unblock_btn' : 'block_btn';
const buttonType = isFiltered ? 'unblock' : 'block';
return (
<div className="logs__action">
<button
type="button"
className={`btn btn-sm ${buttonClass}`}
onClick={() => this.toggleBlocking(buttonType, domain)}
disabled={this.props.filtering.processingRules}
>
<Trans>{buttonText}</Trans>
</button>
</div>
);
}
getDateCell = (row) => CellWrap(
row,
(isToday(row.value) ? formatTime : formatDateTime),
formatDateTime,
return (
<>
{enabled && processingGetConfig && <Loading />}
{enabled && !processingGetConfig && (
<Fragment>
<Filters
filter={filter}
setIsLoading={setIsLoading}
processingGetLogs={processingGetLogs}
processingAdditionalLogs={processingAdditionalLogs}
setLogsFilter={setLogsFilter}
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--small icon-cross d-block d-md-none cursor--pointer"
onClick={closeModal}>
<use xlinkHref="#cross" />
</svg>
{processContent(detailedDataCurrent, buttonType)}
</Modal>
</Fragment>
)}
{!enabled && !processingGetConfig && (
<Disabled />
)}
</>
);
getDomainCell = (row) => {
const response = row.value;
const trackerData = getTrackerData(response);
return (
<div className="logs__row" title={response}>
<div className="logs__text">{response}</div>
{trackerData && <Popover data={trackerData} />}
</div>
);
};
normalizeResponse = (response) => (
response.map((response) => {
const { value, type, ttl } = response;
return `${type}: ${value} (ttl=${ttl})`;
})
);
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;
}
getResponseCell = ({ value: responses, original }) => {
const {
reason, filterId, rule, status, originalAnswer,
} = original;
const { t, filtering } = this.props;
const { filters, whitelistFilters } = filtering;
const isFiltered = checkFiltered(reason);
const isBlackList = checkBlackList(reason);
const isRewrite = checkRewrite(reason);
const isRewriteAuto = checkRewriteHosts(reason);
const isWhiteList = checkWhiteList(reason);
const isBlockedService = checkBlockedService(reason);
const isBlockedCnameIp = originalAnswer;
const filterKey = reason.replace(FILTERED, '');
const parsedFilteredReason = t('query_log_filtered', { filter: filterKey });
const currentService = SERVICES.find((service) => service.id === original.serviceName);
const serviceName = currentService && currentService.name;
const filterName = this.getFilterName(filters, whitelistFilters, filterId, t);
if (isBlockedCnameIp) {
const normalizedAnswer = this.normalizeResponse(originalAnswer);
return (
<div className="logs__row logs__row--column">
<div className="logs__text-wrap">
<span className="logs__text">
<Trans>blocked_by_response</Trans>
</span>
{this.renderTooltip(isFiltered, rule, filterName)}
</div>
<div className="logs__list-wrap">
{this.renderResponseList(normalizedAnswer, status)}
</div>
</div>
);
}
return (
<div className="logs__row logs__row--column">
<div className="logs__text-wrap">
{(isFiltered || isBlockedService) && !isBlackList && (
<span className="logs__text" title={parsedFilteredReason}>
{parsedFilteredReason}
</span>
)}
{isBlackList && (
<span className="logs__text">
<Trans values={{ filter: filterName }}>
query_log_filtered
</Trans>
</span>
)}
{isBlockedService
? this.renderTooltip(isFiltered, '', '', serviceName)
: this.renderTooltip(isFiltered, rule, filterName)}
{isRewrite && (
<strong>
<Trans>rewrite_applied</Trans>
</strong>
)}
{isRewriteAuto && (
<span className="logs__text">
<strong>
<Trans>rewrite_hosts_applied</Trans>
</strong>
</span>
)}
</div>
<div className="logs__list-wrap">
{this.renderResponseList(responses, status)}
{isWhiteList && this.renderTooltip(isWhiteList, rule, filterName)}
</div>
</div>
);
};
getClientCell = (row) => {
const { original } = row;
const { t } = this.props;
const { reason, domain } = original;
const isFiltered = checkFiltered(reason);
const isRewrite = checkRewrite(reason);
const isAutoRewrite = checkRewriteHosts(reason);
if (isAutoRewrite) {
return (
<div className="logs__row logs__row--overflow logs__row--column">
{formatClientCell(row, t)}
</div>
);
}
return (
<Fragment>
<div className="logs__row logs__row--overflow logs__row--column">
{formatClientCell(row, t)}
</div>
{isRewrite ? (
<div className="logs__action">
<Link to="/dns_rewrites" className="btn btn-sm btn-outline-primary">
<Trans>configure</Trans>
</Link>
</div>
) : (
this.renderBlockingButton(isFiltered, domain)
)}
</Fragment>
);
};
fetchData = (state) => {
const { pages } = state;
const { oldest, page } = this.props.queryLogs;
const isLastPage = pages && (page + 1 === pages);
if (isLastPage) {
this.getLogs(oldest, page);
}
};
changePage = (page) => {
this.props.setLogsPage(page);
this.props.setLogsPagination({ page, pageSize: TABLE_DEFAULT_PAGE_SIZE });
};
renderLogs() {
const { queryLogs, t } = this.props;
const {
processingGetLogs, processingGetConfig, logs, pages, page,
} = queryLogs;
const isLoading = processingGetLogs || processingGetConfig;
const columns = [
{
Header: t('time_table_header'),
accessor: 'time',
minWidth: 105,
Cell: this.getDateCell,
},
{
Header: t('domain_name_table_header'),
accessor: 'domain',
minWidth: 180,
Cell: this.getDomainCell,
},
{
Header: t('type_table_header'),
accessor: 'type',
maxWidth: 60,
},
{
Header: t('response_table_header'),
accessor: 'response',
minWidth: 250,
Cell: this.getResponseCell,
},
{
Header: t('client_table_header'),
accessor: 'client',
maxWidth: 240,
minWidth: 240,
Cell: this.getClientCell,
},
];
return (
<ReactTable
manual
minRows={5}
page={page}
pages={pages}
columns={columns}
filterable={false}
sortable={false}
data={logs || []}
loading={isLoading}
showPagination={true}
showPaginationTop={true}
showPageJump={false}
showPageSizeOptions={false}
onFetchData={this.fetchData}
onPageChange={this.changePage}
className="logs__table"
defaultPageSize={TABLE_DEFAULT_PAGE_SIZE}
previousText={t('previous_btn')}
nextText={t('next_btn')}
loadingText={t('loading_table_status')}
rowsText={t('rows_table_footer_text')}
noDataText={t('no_logs_found')}
pageText={''}
ofText={''}
renderTotalPagesCount={() => false}
defaultFilterMethod={(filter, row) => {
const id = filter.pivotId || filter.id;
return row[id] !== undefined
? String(row[id]).indexOf(filter.value) !== -1
: true;
}}
defaultSorted={[
{
id: 'time',
desc: true,
},
]}
getTrProps={(_state, rowInfo) => {
if (!rowInfo) {
return {};
}
const { reason } = rowInfo.original;
if (checkFiltered(reason)) {
return {
className: 'red',
};
} if (checkWhiteList(reason)) {
return {
className: 'green',
};
} if (checkRewrite(reason) || checkRewriteHosts(reason)) {
return {
className: 'blue',
};
}
return {
className: '',
};
}}
/>
);
}
render() {
const { queryLogs, t } = this.props;
const {
enabled, processingGetConfig, processingAdditionalLogs, processingGetLogs,
} = queryLogs;
const refreshButton = enabled ? (
<button
type="button"
className="btn btn-icon btn-outline-primary btn-sm ml-3"
onClick={this.refreshLogs}
>
<svg className="icons">
<use xlinkHref="#refresh" />
</svg>
</button>
) : (
''
);
return (
<Fragment>
<PageTitle title={t('query_log')}>{refreshButton}</PageTitle>
{enabled && processingGetConfig && <Loading />}
{enabled && !processingGetConfig && (
<Fragment>
<Filters
filter={queryLogs.filter}
processingGetLogs={processingGetLogs}
processingAdditionalLogs={processingAdditionalLogs}
setLogsFilter={this.props.setLogsFilter}
/>
<Card>{this.renderLogs()}</Card>
</Fragment>
)}
{!enabled && !processingGetConfig && (
<Card>
<div className="lead text-center py-6">
<Trans
components={[
<Link to="/settings#logs-config" key="0">
link
</Link>,
]}
>
query_log_disabled
</Trans>
</div>
</Card>
)}
</Fragment>
);
}
}
};
Logs.propTypes = {
getLogs: PropTypes.func.isRequired,
@@ -461,12 +214,11 @@ Logs.propTypes = {
filtering: PropTypes.object.isRequired,
setRules: PropTypes.func.isRequired,
addSuccessToast: PropTypes.func.isRequired,
getClients: PropTypes.func.isRequired,
getLogsConfig: PropTypes.func.isRequired,
setLogsPagination: PropTypes.func.isRequired,
setLogsFilter: PropTypes.func.isRequired,
setLogsPage: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
toggleDetailedLogs: PropTypes.func.isRequired,
dnsConfig: PropTypes.object.isRequired,
};
export default withTranslation()(Logs);
export default Logs;

View File

@@ -79,16 +79,26 @@ class AutoClients extends Component {
},
]}
className="-striped -highlight card-table-overflow"
showPagination={true}
showPagination
defaultPageSize={10}
minRows={5}
previousText={t('previous_btn')}
nextText={t('next_btn')}
showPageSizeOptions={false}
showPageJump={false}
renderTotalPagesCount={() => false}
previousText={
<svg className="icons icon--small icon--gray w-100 h-100">
<use xlinkHref="#arrow-left" />
</svg>}
nextText={
<svg className="icons icon--small icon--gray w-100 h-100">
<use xlinkHref="#arrow-right" />
</svg>}
loadingText={t('loading_table_status')}
pageText={t('page_table_footer_text')}
ofText="/"
pageText=''
ofText=''
rowsText={t('rows_table_footer_text')}
noDataText={t('clients_not_found')}
getPaginationProps={() => ({ className: 'custom-pagination' })}
/>
</Card>
);

View File

@@ -91,7 +91,7 @@ class ClientsTable extends Component {
const { value } = row;
return (
<div className="logs__row logs__row--overflow">
<div className="logs__row o-hidden">
<span className="logs__text">
{value.map((address) => (
<div key={address} title={address}>
@@ -121,7 +121,7 @@ class ClientsTable extends Component {
);
return (
<div className="logs__row logs__row--overflow">
<div className="logs__row o-hidden">
<div className="logs__text">{title}</div>
</div>
);
@@ -167,7 +167,7 @@ class ClientsTable extends Component {
);
return (
<div className="logs__row logs__row--overflow">
<div className="logs__row o-hidden">
<div className="logs__text">{title}</div>
</div>
);
@@ -185,7 +185,7 @@ class ClientsTable extends Component {
}
return (
<div className="logs__row logs__row--overflow">
<div className="logs__row o-hidden">
<span className="logs__text">
{value.map((tag) => (
<div key={tag} title={tag} className="small">
@@ -282,16 +282,26 @@ class ClientsTable extends Component {
},
]}
className="-striped -highlight card-table-overflow"
showPagination={true}
showPagination
defaultPageSize={10}
minRows={5}
previousText={t('previous_btn')}
nextText={t('next_btn')}
showPageSizeOptions={false}
showPageJump={false}
renderTotalPagesCount={() => false}
previousText={
<svg className="icons icon--small icon--gray w-100 h-100">
<use xlinkHref="#arrow-left" />
</svg>}
nextText={
<svg className="icons icon--small icon--gray w-100 h-100">
<use xlinkHref="#arrow-right" />
</svg>}
loadingText={t('loading_table_status')}
pageText={t('page_table_footer_text')}
ofText="/"
pageText=''
ofText=''
rowsText={t('rows_table_footer_text')}
noDataText={t('clients_not_found')}
getPaginationProps={() => ({ className: 'custom-pagination' })}
/>
<button
type="button"

View File

@@ -89,7 +89,7 @@ const renderFieldsWrapper = (placeholder, buttonTitle) => function cell(row) {
onClick={() => fields.push()}
title={buttonTitle}
>
<svg className="icon icon--close">
<svg className="icon icon--small">
<use xlinkHref="#plus" />
</svg>
</button>

View File

@@ -32,11 +32,9 @@ const getFormattedWhois = (value, t) => {
const whoisCell = (t) => function cell(row) {
const { value } = row;
return (
<div className="logs__row logs__row--overflow">
<span className="logs__text logs__text--wrap">{getFormattedWhois(value, t)}</span>
</div>
);
return <div className="logs__row o-hidden">
<div className="logs__text logs__text--wrap">{getFormattedWhois(value, t)}</div>
</div>;
};
export default whoisCell;

View File

@@ -2,11 +2,11 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types';
import ReactTable from 'react-table';
import { Trans, withTranslation } from 'react-i18next';
import { SMALL_TABLE_DEFAULT_PAGE_SIZE } from '../../../helpers/constants';
import { LEASES_TABLE_DEFAULT_PAGE_SIZE } from '../../../helpers/constants';
class Leases extends Component {
cellWrap = ({ value }) => (
<div className="logs__row logs__row--overflow">
<div className="logs__row o-hidden">
<span className="logs__text" title={value}>
{value}
</span>
@@ -37,9 +37,9 @@ class Leases extends Component {
Cell: this.cellWrap,
},
]}
pageSize={SMALL_TABLE_DEFAULT_PAGE_SIZE}
pageSize={LEASES_TABLE_DEFAULT_PAGE_SIZE}
showPageSizeOptions={false}
showPagination={leases.length > SMALL_TABLE_DEFAULT_PAGE_SIZE}
showPagination={leases.length > LEASES_TABLE_DEFAULT_PAGE_SIZE}
noDataText={t('dhcp_leases_not_found')}
minRows={6}
className="-striped -highlight card-table-overflow"

View File

@@ -2,13 +2,13 @@ import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import ReactTable from 'react-table';
import { Trans, withTranslation } from 'react-i18next';
import { SMALL_TABLE_DEFAULT_PAGE_SIZE } from '../../../../helpers/constants';
import { LEASES_TABLE_DEFAULT_PAGE_SIZE } from '../../../../helpers/constants';
import Modal from './Modal';
class StaticLeases extends Component {
cellWrap = ({ value }) => (
<div className="logs__row logs__row--overflow">
<div className="logs__row o-hidden">
<span className="logs__text" title={value}>
{value}
</span>
@@ -67,7 +67,7 @@ class StaticLeases extends Component {
<div className="logs__row logs__row--center">
<button
type="button"
className="btn btn-icon btn-outline-secondary btn-sm"
className="btn btn-icon btn-icon--green btn-outline-secondary btn-sm"
title={t('delete_table_action')}
disabled={processingDeleting}
onClick={() => this.handleDelete(ip, mac, hostname)
@@ -82,9 +82,9 @@ class StaticLeases extends Component {
},
},
]}
pageSize={SMALL_TABLE_DEFAULT_PAGE_SIZE}
pageSize={LEASES_TABLE_DEFAULT_PAGE_SIZE}
showPageSizeOptions={false}
showPagination={staticLeases.length > SMALL_TABLE_DEFAULT_PAGE_SIZE}
showPagination={staticLeases.length > LEASES_TABLE_DEFAULT_PAGE_SIZE}
noDataText={t('dhcp_static_leases_not_found')}
className="-striped -highlight card-table-overflow"
minRows={6}

View File

@@ -112,6 +112,11 @@
justify-content: center;
width: 30px;
height: 30px;
background-color: transparent;
}
.btn-icon--green {
color: var(--green);
}
.btn-icon-sm {

View File

@@ -6,6 +6,13 @@
width: 345px;
}
@media (max-width: 425px) {
.toasts {
right: 0;
width: 100%;
}
}
.toast {
display: flex;
align-items: flex-start;

View File

@@ -1,20 +1,33 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Trans, withTranslation } from 'react-i18next';
import { FAILURE_TOAST_TIMEOUT, SUCCESS_TOAST_TIMEOUT } from '../../helpers/constants';
class Toast extends Component {
componentDidMount() {
const timeout = this.props.type === 'success' ? 5000 : 30000;
state = {
timerId: null,
};
setTimeout(() => {
this.props.removeToast(this.props.id);
}, timeout);
componentDidMount() {
this.setRemoveToastTimeout();
}
shouldComponentUpdate() {
return false;
}
clearRemoveToastTimeout = () => clearTimeout(this.state.timerId);
setRemoveToastTimeout = () => {
const timeout = this.props.type === 'success' ? SUCCESS_TOAST_TIMEOUT : FAILURE_TOAST_TIMEOUT;
const timerId = setTimeout(() => {
this.props.removeToast(this.props.id);
}, timeout);
this.setState({ timerId });
};
showMessage(t, type, message) {
if (type === 'notice') {
return <span dangerouslySetInnerHTML={{ __html: t(message) }} />;
@@ -29,12 +42,18 @@ class Toast extends Component {
} = this.props;
return (
<div className={`toast toast--${type}`}>
<div className={`toast toast--${type}`}
onMouseOver={this.clearRemoveToastTimeout}
onMouseOut={this.setRemoveToastTimeout}>
<p className="toast__content">
{this.showMessage(t, type, message)}
</p>
<button className="toast__dismiss" onClick={() => this.props.removeToast(id)}>
<svg stroke="#fff" fill="none" width="20" height="20" strokeWidth="2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m18 6-12 12"/><path d="m6 6 12 12"/></svg>
<svg stroke="#fff" fill="none" width="20" height="20" strokeWidth="2"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="m18 6-12 12" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
);

View File

@@ -9,7 +9,7 @@ const CellWrap = ({ value }, formatValue, formatTitle = formatValue) => {
const cellTitle = typeof formatTitle === 'function' ? formatTitle(value) : value;
return (
<div className="logs__row logs__row--overflow">
<div className="logs__row o-hidden">
<span className="logs__text logs__text--full" title={cellTitle}>
{cellValue}
</span>

View File

@@ -4,7 +4,19 @@
height: 100%;
}
.icon--close {
width: 24px;
height: 24px;
.icon--small {
width: 1.5rem;
height: 1.5rem;
}
.icon--gray {
color: var(--gray-a5);
}
.icon--disabled {
color: var(--gray-d8);
}
.icon--active {
color: #66b574;
}

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@ import React from 'react';
import './Loading.css';
const Loading = () => (
<div className="loading"></div>
<div className="loading" />
);
export default Loading;

View File

@@ -3,13 +3,32 @@
align-items: flex-start;
}
.page-header--logs {
flex-direction: row;
align-items: flex-end;
margin: 2rem 0 3rem;
}
@media (max-width: 991px) {
.page-header--logs {
flex-direction: column;
align-items: center;
margin-bottom: 1.5rem;
}
.page-header--logs .page-title {
padding-bottom: 2.5rem;;
}
}
.page-subtitle {
margin-left: 0;
font-size: 0.9rem;
}
.page-title {
line-height: 2.2rem;
.page-title--large {
font-size: 36px;
line-height: 46px;
}
.page-title__actions {

View File

@@ -3,15 +3,15 @@ import PropTypes from 'prop-types';
import './PageTitle.css';
const PageTitle = (props) => (
const PageTitle = ({ title, subtitle, children }) => (
<div className="page-header">
<h1 className="page-title">
{props.title}
{props.children}
{title}
{children}
</h1>
{props.subtitle && (
{subtitle && (
<div className="page-subtitle">
{props.subtitle}
{subtitle}
</div>
)}
</div>

View File

@@ -1,21 +1,26 @@
.ReactTable .rt-th,
.ReactTable .rt-td {
padding: 10px 15px;
overflow: visible;
overflow: hidden;
text-overflow: ellipsis;
}
.ReactTable .rt-tbody {
overflow: visible;
}
.rt-tr-group .red {
background-color: #fff4f2;
.rt-tr-group.red {
background-color: rgba(223, 56, 18, 0.05);
}
.rt-tr-group .green {
background-color: #f1faf3;
.rt-tr-group.green {
background-color: rgba(103, 178, 121, 0.1);
}
.rt-tr-group .blue {
background-color: #ecf7ff;
.rt-tr-group.blue {
background-color: #e5effd;
}
.rt-tr-group.yellow {
background-color: var(--yellow-pale);
}

View File

@@ -6,7 +6,6 @@
width: 18px;
height: 18px;
flex-shrink: 0;
margin-left: 5px;
background-image: url("./svg/help-circle.svg");
background-size: 100%;
cursor: pointer;
@@ -56,7 +55,6 @@
.tooltip-custom--logs {
border-radius: 50%;
background-image: url("./svg/help-circle-gray.svg");
background-color: #fff;
}
.tooltip-custom--logs:before {

View File

@@ -3,9 +3,8 @@ import PropTypes from 'prop-types';
import './Tooltip.css';
const Tooltip = (props) => (
<div data-tooltip={props.text} className={`tooltip-custom ${props.type || ''}`}></div>
);
const Tooltip = ({ text, type = '' }) => <div data-tooltip={text}
className={`tooltip-custom ml-1 ${type}`} />;
Tooltip.propTypes = {
text: PropTypes.string.isRequired,