From e43ba178845b6ce9387b8fb07345161b24235c53 Mon Sep 17 00:00:00 2001 From: Artem Krisanov <a.krisanov@adguard.com> Date: Mon, 17 Apr 2023 15:57:57 +0300 Subject: [PATCH] AG-21212 - Custom logs and stats retention Updates#3404 Squashed commit of the following: commit b68a1d08b0676ebb7abbb13c9274c8d509cd6eed Merge: 81265147 6d402dc8 Author: Artem Krisanov <a.krisanov@adguard.com> Date: Mon Apr 17 15:48:33 2023 +0300 Merge master commit 81265147b5613be11a6621a416f9588c0e1c0ef5 Author: Artem Krisanov <a.krisanov@adguard.com> Date: Thu Apr 13 10:54:39 2023 +0300 Changed query log 'retention' --> 'rotation'. commit 02c5dc0b54bca9ec293ee8629d769489bc5dc533 Author: Artem Krisanov <a.krisanov@adguard.com> Date: Wed Apr 12 13:22:22 2023 +0300 Custom inputs for query log and stats configs. commit 21dbfbd8aac868baeea0f8b25d14786aecf09a0d Author: Artem Krisanov <a.krisanov@adguard.com> Date: Tue Apr 11 18:12:40 2023 +0300 Temporary changes. --- client/src/__locales/en.json | 6 +- .../components/Settings/LogsConfig/Form.js | 91 +++++++++++++++++-- .../components/Settings/LogsConfig/index.js | 24 ++++- client/src/components/Settings/Settings.css | 5 + .../components/Settings/StatsConfig/Form.js | 83 +++++++++++++++-- .../components/Settings/StatsConfig/index.js | 19 +++- client/src/components/Settings/index.js | 4 + client/src/helpers/constants.js | 11 +++ client/src/reducers/queryLogs.js | 8 +- client/src/reducers/stats.js | 6 +- 10 files changed, 233 insertions(+), 24 deletions(-) diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json index 8a6a9d10..a2633ad8 100644 --- a/client/src/__locales/en.json +++ b/client/src/__locales/en.json @@ -257,12 +257,12 @@ "query_log_cleared": "The query log has been successfully cleared", "query_log_updated": "The query log has been successfully updated", "query_log_clear": "Clear query logs", - "query_log_retention": "Query logs retention", + "query_log_retention": "Query logs rotation", "query_log_enable": "Enable log", "query_log_configuration": "Logs configuration", "query_log_disabled": "The query log is disabled and can be configured in the <0>settings</0>", "query_log_strict_search": "Use double quotes for strict search", - "query_log_retention_confirm": "Are you sure you want to change query log retention? If you decrease the interval value, some data will be lost", + "query_log_retention_confirm": "Are you sure you want to change query log rotation? If you decrease the interval value, some data will be lost", "anonymize_client_ip": "Anonymize client IP", "anonymize_client_ip_desc": "Don't save the client's full IP address to logs or statistics", "dns_config": "DNS server configuration", @@ -669,6 +669,8 @@ "disable_notify_for_hours_plural": "Disable protection for {{count}} hours", "disable_notify_until_tomorrow": "Disable protection until tomorrow", "enable_protection_timer": "Protection will be enabled in {{time}}", + "custom_retention_input": "Enter retention in hours", + "custom_rotation_input": "Enter rotation in hours", "protection_section_label": "Protection", "log_and_stats_section_label": "Query log and statistics", "ignore_query_log": "Ignore this client in query log", diff --git a/client/src/components/Settings/LogsConfig/Form.js b/client/src/components/Settings/LogsConfig/Form.js index b29b974e..dbecc183 100644 --- a/client/src/components/Settings/LogsConfig/Form.js +++ b/client/src/components/Settings/LogsConfig/Form.js @@ -1,25 +1,37 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; -import { Field, reduxForm } from 'redux-form'; +import { + change, + Field, + formValueSelector, + reduxForm, +} from 'redux-form'; +import { connect } from 'react-redux'; import { Trans, withTranslation } from 'react-i18next'; import flow from 'lodash/flow'; import { CheckboxField, - renderRadioField, toFloatNumber, - renderTextareaField, + renderTextareaField, renderInputField, renderRadioField, } from '../../../helpers/form'; import { FORM_NAME, QUERY_LOG_INTERVALS_DAYS, HOUR, DAY, + RETENTION_CUSTOM, + RETENTION_CUSTOM_INPUT, + RETENTION_RANGE, + CUSTOM_INTERVAL, } from '../../../helpers/constants'; import '../FormButton.css'; + const getIntervalTitle = (interval, t) => { switch (interval) { + case RETENTION_CUSTOM: + return t('settings_custom'); case 6 * HOUR: return t('interval_6_hour'); case DAY: @@ -42,11 +54,26 @@ const getIntervalFields = (processing, t, toNumber) => QUERY_LOG_INTERVALS_DAYS. /> )); -const Form = (props) => { +let Form = (props) => { const { - handleSubmit, submitting, invalid, processing, processingClear, handleClear, t, + handleSubmit, + submitting, + invalid, + processing, + processingClear, + handleClear, + t, + interval, + customInterval, + dispatch, } = props; + useEffect(() => { + if (QUERY_LOG_INTERVALS_DAYS.includes(interval)) { + dispatch(change(FORM_NAME.LOG_CONFIG, CUSTOM_INTERVAL, null)); + } + }, [interval]); + return ( <form onSubmit={handleSubmit}> <div className="form__group form__group--settings"> @@ -73,6 +100,37 @@ const Form = (props) => { </label> <div className="form__group form__group--settings"> <div className="custom-controls-stacked"> + <Field + key={RETENTION_CUSTOM} + name="interval" + type="radio" + component={renderRadioField} + value={QUERY_LOG_INTERVALS_DAYS.includes(interval) + ? RETENTION_CUSTOM + : interval + } + placeholder={getIntervalTitle(RETENTION_CUSTOM, t)} + normalize={toFloatNumber} + disabled={processing} + /> + {!QUERY_LOG_INTERVALS_DAYS.includes(interval) && ( + <div className="form__group--input"> + <div className="form__desc form__desc--top"> + {t('custom_rotation_input')} + </div> + <Field + key={RETENTION_CUSTOM_INPUT} + name={CUSTOM_INTERVAL} + type="number" + className="form-control" + component={renderInputField} + disabled={processing} + normalize={toFloatNumber} + min={RETENTION_RANGE.MIN} + max={RETENTION_RANGE.MAX} + /> + </div> + )} {getIntervalFields(processing, t, toFloatNumber)} </div> </div> @@ -96,7 +154,12 @@ const Form = (props) => { <button type="submit" className="btn btn-success btn-standard btn-large" - disabled={submitting || invalid || processing} + disabled={ + submitting + || invalid + || processing + || (!QUERY_LOG_INTERVALS_DAYS.includes(interval) && !customInterval) + } > <Trans>save_btn</Trans> </button> @@ -121,8 +184,22 @@ Form.propTypes = { processing: PropTypes.bool.isRequired, processingClear: PropTypes.bool.isRequired, t: PropTypes.func.isRequired, + interval: PropTypes.number, + customInterval: PropTypes.number, + dispatch: PropTypes.func.isRequired, }; +const selector = formValueSelector(FORM_NAME.LOG_CONFIG); + +Form = connect((state) => { + const interval = selector(state, 'interval'); + const customInterval = selector(state, CUSTOM_INTERVAL); + return { + interval, + customInterval, + }; +})(Form); + export default flow([ withTranslation(), reduxForm({ form: FORM_NAME.LOG_CONFIG }), diff --git a/client/src/components/Settings/LogsConfig/index.js b/client/src/components/Settings/LogsConfig/index.js index 146a77b0..3e609a2d 100644 --- a/client/src/components/Settings/LogsConfig/index.js +++ b/client/src/components/Settings/LogsConfig/index.js @@ -4,15 +4,22 @@ import { withTranslation } from 'react-i18next'; import Card from '../../ui/Card'; import Form from './Form'; +import { HOUR } from '../../../helpers/constants'; class LogsConfig extends Component { handleFormSubmit = (values) => { const { t, interval: prevInterval } = this.props; - const { interval } = values; + const { interval, customInterval, ...rest } = values; - const data = { ...values, ignored: values.ignored ? values.ignored.split('\n') : [] }; + const newInterval = customInterval ? customInterval * HOUR : interval; - if (interval !== prevInterval) { + const data = { + ...rest, + ignored: values.ignored ? values.ignored.split('\n') : [], + interval: newInterval, + }; + + if (newInterval < prevInterval) { // eslint-disable-next-line no-alert if (window.confirm(t('query_log_retention_confirm'))) { this.props.setLogsConfig(data); @@ -32,7 +39,14 @@ class LogsConfig extends Component { render() { const { - t, enabled, interval, processing, processingClear, anonymize_client_ip, ignored, + t, + enabled, + interval, + processing, + processingClear, + anonymize_client_ip, + ignored, + customInterval, } = this.props; return ( @@ -46,6 +60,7 @@ class LogsConfig extends Component { initialValues={{ enabled, interval, + customInterval, anonymize_client_ip, ignored: ignored.join('\n'), }} @@ -62,6 +77,7 @@ class LogsConfig extends Component { LogsConfig.propTypes = { interval: PropTypes.number.isRequired, + customInterval: PropTypes.number, enabled: PropTypes.bool.isRequired, anonymize_client_ip: PropTypes.bool.isRequired, processing: PropTypes.bool.isRequired, diff --git a/client/src/components/Settings/Settings.css b/client/src/components/Settings/Settings.css index b6903427..25532b45 100644 --- a/client/src/components/Settings/Settings.css +++ b/client/src/components/Settings/Settings.css @@ -18,6 +18,11 @@ font-size: 14px; } +.form__group--input { + max-width: 300px; + margin: 0 1.5rem 10px; +} + .form__group--checkbox { margin-bottom: 25px; } diff --git a/client/src/components/Settings/StatsConfig/Form.js b/client/src/components/Settings/StatsConfig/Form.js index e9cd02fd..087e9578 100644 --- a/client/src/components/Settings/StatsConfig/Form.js +++ b/client/src/components/Settings/StatsConfig/Form.js @@ -1,32 +1,44 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; -import { Field, reduxForm } from 'redux-form'; +import { + change, Field, formValueSelector, reduxForm, +} from 'redux-form'; import { Trans, withTranslation } from 'react-i18next'; import flow from 'lodash/flow'; +import { connect } from 'react-redux'; + import { renderRadioField, toNumber, CheckboxField, renderTextareaField, + toFloatNumber, + renderInputField, } from '../../../helpers/form'; import { FORM_NAME, STATS_INTERVALS_DAYS, DAY, + RETENTION_CUSTOM, + RETENTION_CUSTOM_INPUT, + CUSTOM_INTERVAL, + RETENTION_RANGE, } from '../../../helpers/constants'; import '../FormButton.css'; const getIntervalTitle = (intervalMs, t) => { - switch (intervalMs / DAY) { - case 1: + switch (intervalMs) { + case RETENTION_CUSTOM: + return t('settings_custom'); + case DAY: return t('interval_24_hour'); default: return t('interval_days', { count: intervalMs / DAY }); } }; -const Form = (props) => { +let Form = (props) => { const { handleSubmit, processing, @@ -35,8 +47,17 @@ const Form = (props) => { handleReset, processingReset, t, + interval, + customInterval, + dispatch, } = props; + useEffect(() => { + if (STATS_INTERVALS_DAYS.includes(interval)) { + dispatch(change(FORM_NAME.STATS_CONFIG, CUSTOM_INTERVAL, null)); + } + }, [interval]); + return ( <form onSubmit={handleSubmit}> <div className="form__group form__group--settings"> @@ -56,6 +77,37 @@ const Form = (props) => { </div> <div className="form__group form__group--settings mt-2"> <div className="custom-controls-stacked"> + <Field + key={RETENTION_CUSTOM} + name="interval" + type="radio" + component={renderRadioField} + value={STATS_INTERVALS_DAYS.includes(interval) + ? RETENTION_CUSTOM + : interval + } + placeholder={getIntervalTitle(RETENTION_CUSTOM, t)} + normalize={toFloatNumber} + disabled={processing} + /> + {!STATS_INTERVALS_DAYS.includes(interval) && ( + <div className="form__group--input"> + <div className="form__desc form__desc--top"> + {t('custom_retention_input')} + </div> + <Field + key={RETENTION_CUSTOM_INPUT} + name={CUSTOM_INTERVAL} + type="number" + className="form-control" + component={renderInputField} + disabled={processing} + normalize={toFloatNumber} + min={RETENTION_RANGE.MIN} + max={RETENTION_RANGE.MAX} + /> + </div> + )} {STATS_INTERVALS_DAYS.map((interval) => ( <Field key={interval} @@ -90,7 +142,12 @@ const Form = (props) => { <button type="submit" className="btn btn-success btn-standard btn-large" - disabled={submitting || invalid || processing} + disabled={ + submitting + || invalid + || processing + || (!STATS_INTERVALS_DAYS.includes(interval) && !customInterval) + } > <Trans>save_btn</Trans> </button> @@ -116,8 +173,22 @@ Form.propTypes = { processing: PropTypes.bool.isRequired, processingReset: PropTypes.bool.isRequired, t: PropTypes.func.isRequired, + interval: PropTypes.number, + customInterval: PropTypes.number, + dispatch: PropTypes.func.isRequired, }; +const selector = formValueSelector(FORM_NAME.STATS_CONFIG); + +Form = connect((state) => { + const interval = selector(state, 'interval'); + const customInterval = selector(state, CUSTOM_INTERVAL); + return { + interval, + customInterval, + }; +})(Form); + export default flow([ withTranslation(), reduxForm({ form: FORM_NAME.STATS_CONFIG }), diff --git a/client/src/components/Settings/StatsConfig/index.js b/client/src/components/Settings/StatsConfig/index.js index 83807afa..7b68064c 100644 --- a/client/src/components/Settings/StatsConfig/index.js +++ b/client/src/components/Settings/StatsConfig/index.js @@ -4,13 +4,18 @@ import { withTranslation } from 'react-i18next'; import Card from '../../ui/Card'; import Form from './Form'; +import { HOUR } from '../../../helpers/constants'; class StatsConfig extends Component { - handleFormSubmit = ({ enabled, interval, ignored }) => { + handleFormSubmit = ({ + enabled, interval, ignored, customInterval, + }) => { const { t, interval: prevInterval } = this.props; + const newInterval = customInterval ? customInterval * HOUR : interval; + const config = { enabled, - interval, + interval: newInterval, ignored: ignored ? ignored.split('\n') : [], }; @@ -33,7 +38,13 @@ class StatsConfig extends Component { render() { const { - t, interval, processing, processingReset, ignored, enabled, + t, + interval, + customInterval, + processing, + processingReset, + ignored, + enabled, } = this.props; return ( @@ -46,6 +57,7 @@ class StatsConfig extends Component { <Form initialValues={{ interval, + customInterval, enabled, ignored: ignored.join('\n'), }} @@ -62,6 +74,7 @@ class StatsConfig extends Component { StatsConfig.propTypes = { interval: PropTypes.number.isRequired, + customInterval: PropTypes.number, ignored: PropTypes.array.isRequired, enabled: PropTypes.bool.isRequired, processing: PropTypes.bool.isRequired, diff --git a/client/src/components/Settings/index.js b/client/src/components/Settings/index.js index 43aff62e..8ef96bd8 100644 --- a/client/src/components/Settings/index.js +++ b/client/src/components/Settings/index.js @@ -124,6 +124,7 @@ class Settings extends Component { enabled={queryLogs.enabled} ignored={queryLogs.ignored} interval={queryLogs.interval} + customInterval={queryLogs.customInterval} anonymize_client_ip={queryLogs.anonymize_client_ip} processing={queryLogs.processingSetConfig} processingClear={queryLogs.processingClear} @@ -134,6 +135,7 @@ class Settings extends Component { <div className="col-md-12"> <StatsConfig interval={stats.interval} + customInterval={stats.customInterval} ignored={stats.ignored} enabled={stats.enabled} processing={stats.processingSetConfig} @@ -166,6 +168,7 @@ Settings.propTypes = { stats: PropTypes.shape({ processingGetConfig: PropTypes.bool, interval: PropTypes.number, + customInterval: PropTypes.number, enabled: PropTypes.bool, ignored: PropTypes.array, processingSetConfig: PropTypes.bool, @@ -174,6 +177,7 @@ Settings.propTypes = { queryLogs: PropTypes.shape({ enabled: PropTypes.bool, interval: PropTypes.number, + customInterval: PropTypes.number, anonymize_client_ip: PropTypes.bool, processingSetConfig: PropTypes.bool, processingClear: PropTypes.bool, diff --git a/client/src/helpers/constants.js b/client/src/helpers/constants.js index 98bc99e2..c4739489 100644 --- a/client/src/helpers/constants.js +++ b/client/src/helpers/constants.js @@ -220,6 +220,12 @@ export const STATS_INTERVALS_DAYS = [DAY, DAY * 7, DAY * 30, DAY * 90]; export const QUERY_LOG_INTERVALS_DAYS = [HOUR * 6, DAY, DAY * 7, DAY * 30, DAY * 90]; +export const RETENTION_CUSTOM = 1; + +export const RETENTION_CUSTOM_INPUT = 'custom_retention_input'; + +export const CUSTOM_INTERVAL = 'customInterval'; + export const FILTERS_INTERVALS_HOURS = [0, 1, 12, 24, 72, 168]; // Note that translation strings contain these modes (blocking_mode_CONSTANT) @@ -462,6 +468,11 @@ export const UINT32_RANGE = { MAX: 4294967295, }; +export const RETENTION_RANGE = { + MIN: 1, + MAX: 365 * 24, +}; + export const DHCP_VALUES_PLACEHOLDERS = { ipv4: { subnet_mask: '255.255.255.0', diff --git a/client/src/reducers/queryLogs.js b/client/src/reducers/queryLogs.js index 26d47025..89cc0041 100644 --- a/client/src/reducers/queryLogs.js +++ b/client/src/reducers/queryLogs.js @@ -1,7 +1,9 @@ import { handleActions } from 'redux-actions'; import * as actions from '../actions/queryLogs'; -import { DEFAULT_LOGS_FILTER, DAY } from '../helpers/constants'; +import { + DEFAULT_LOGS_FILTER, DAY, QUERY_LOG_INTERVALS_DAYS, HOUR, +} from '../helpers/constants'; const queryLogs = handleActions( { @@ -59,6 +61,9 @@ const queryLogs = handleActions( [actions.getLogsConfigSuccess]: (state, { payload }) => ({ ...state, ...payload, + customInterval: !QUERY_LOG_INTERVALS_DAYS.includes(payload.interval) + ? payload.interval / HOUR + : null, processingGetConfig: false, }), @@ -95,6 +100,7 @@ const queryLogs = handleActions( anonymize_client_ip: false, isDetailed: true, isEntireLog: false, + customInterval: null, }, ); diff --git a/client/src/reducers/stats.js b/client/src/reducers/stats.js index 2e5a7e48..a1c63e14 100644 --- a/client/src/reducers/stats.js +++ b/client/src/reducers/stats.js @@ -1,6 +1,6 @@ import { handleActions } from 'redux-actions'; import { normalizeTopClients } from '../helpers/helpers'; -import { DAY } from '../helpers/constants'; +import { DAY, HOUR, STATS_INTERVALS_DAYS } from '../helpers/constants'; import * as actions from '../actions/stats'; @@ -27,6 +27,9 @@ const stats = handleActions( [actions.getStatsConfigSuccess]: (state, { payload }) => ({ ...state, ...payload, + customInterval: !STATS_INTERVALS_DAYS.includes(payload.interval) + ? payload.interval / HOUR + : null, processingGetConfig: false, }), @@ -93,6 +96,7 @@ const stats = handleActions( processingStats: true, processingReset: false, interval: DAY, + customInterval: null, ...defaultStats, }, );