ADG-10293 common settings page

This commit is contained in:
Ildar Kamalov
2025-08-09 12:55:47 +03:00
parent ba09a14c05
commit b3ca6c9934
56 changed files with 2084 additions and 510 deletions

View File

@@ -97,7 +97,6 @@
"settings": "Settings",
"filters": "Filters",
"filter": "Filter",
"query_log": "Query Log",
"compact": "Compact",
"nothing_found": "Nothing found",
"faq": "FAQ",
@@ -195,7 +194,6 @@
"no_whitelist_added": "No allowlists added",
"add_blocklist": "Add blocklist",
"add_allowlist": "Add allowlist",
"cancel_btn": "Cancel",
"enter_name_hint": "Enter name",
"enter_url_or_path_hint": "Enter a URL or an absolute path of the list",
"check_updates_btn": "Check for updates",
@@ -452,10 +450,8 @@
"clients_title": "Persistent clients",
"clients_desc": "Configure persistent client records for devices connected to AdGuard Home",
"settings_global": "Global",
"settings_custom": "Custom",
"table_client": "Client",
"table_name": "Name",
"save_btn": "Save",
"client_add": "Add Client",
"client_new": "New Client",
"client_edit": "Edit Client",
@@ -762,5 +758,52 @@
"release_notes": "Release notes",
"privacy_policy": "Privacy policy",
"selected": "%value% selected",
"general_settings_short": "General"
"settings_general_short": "General",
"settings_filtering_and_security": "Filtering and security",
"settings_filter_requests": "Filter requests",
"settings_filter_requests_desc": "You can use <a>Blocklists</a>, <b>Allowlists</b>, and <c>User rules</c> to set up filtering rules",
"settings_browsing_security": "Browsing security",
"settings_browsing_security_desc": "Block access to domains found in the database of malicious and phishing domains",
"settings_parental_control": "Parental control",
"settings_parental_control_desc": "Block websites with content inappropriate for children",
"settings_safe_search": "Safe search",
"settings_safe_search_desc": "Block inappropriate content in search results",
"settings_log_dns_requests": "Log DNS requests",
"settings_anonymize_client_ip": "Anonymize client IP addresses",
"settings_anonymize_client_ip_desc": "Replace part of the IP address with zeros",
"settings_log_rotation": "Log rotation",
"settings_hours": "| %count% hour | %count% hours",
"settings_days": "| %count% day | %count% days",
"settings_custom": "Custom",
"settings_log_rotation_hours": "Log rotation, in hours",
"settings_rotation_placeholder": "Use numbers",
"settings_log_ignored_domains": "Ignored domains",
"settings_log_ignored_domains_desc": "Requests that match these rules are not displayed in Query log",
"settings_domain_names": "Domain names, one per line",
"settings_rule_syntax": "Rule syntax",
"save": "Save",
"cancel": "Cancel",
"query_log": "Query Log",
"clear_query_log": "Clear Query log",
"settings_statistics": "Statistics",
"settings_statistics_desc": "Display request and client statistics on the dashboard",
"settings_statistics_retention": "Statistics retention",
"settings_statistics_retention_hours": "Statistics retention, in hours",
"settings_statistics_ignored_domains": "Requests that match these rules are not included in the statistics",
"settings_statistics_clear": "Clear statistics",
"settings_confirm_decrease_log_rotation_interval": "Decrease log rotation interval?",
"settings_confirm_decrease_log_rotation_interval_desc": "This will delete all logs older than %value%",
"settings_yes_decrease": "Yes, decrease",
"settings_confirm_decrease_stats_rotation_interval": "Decrease stats retention interval?",
"settings_confirm_decrease_stats_rotation_interval_desc": "This will delete all stats older than %value%",
"settings_confirm_clear_statistics": "Clear statistics?",
"settings_confirm_clear_statistics_desc": "This will clear all statistics. Query log will still be available",
"settings_yes_clear": "Yes, clear",
"settings_confirm_clear_query_log": "Clear Query log?",
"settings_confirm_clear_query_log_desc": "This will delete all requests from <strong>Query log</strong>. Statistics will still be available",
"settings_tooltip_domain_names": "Domain names, wildcards, or filtering rules",
"settings_tooltip_examples": "Examples:",
"settings_notify_changes_saved": "Changes saved",
"settings_notify_query_log_cleared": "Query log cleared",
"settings_notify_statistics_cleared": "Statistics cleared"
}

View File

@@ -5,6 +5,7 @@ import { apiClient } from '../api/Api';
import { splitByNewLine } from '../helpers/helpers';
import { addErrorToast, addSuccessToast } from './toasts';
import intl from 'panel/common/intl';
export const getDnsConfigRequest = createAction('GET_DNS_CONFIG_REQUEST');
export const getDnsConfigFailure = createAction('GET_DNS_CONFIG_FAILURE');
@@ -30,7 +31,7 @@ export const clearDnsCache = () => async (dispatch: any) => {
try {
const data = await apiClient.clearCache();
dispatch(clearDnsCacheSuccess(data));
dispatch(addSuccessToast(i18next.t('cache_cleared')));
dispatch(addSuccessToast(intl.getMessage('cache_cleared')));
} catch (error) {
dispatch(addErrorToast({ error }));
dispatch(clearDnsCacheFailure());
@@ -71,9 +72,9 @@ export const setDnsConfig = (config: any) => async (dispatch: any) => {
await apiClient.setDnsConfig(data);
if (hasDnsSettings) {
dispatch(addSuccessToast('updated_upstream_dns_toast'));
dispatch(addSuccessToast(intl.getMessage('updated_upstream_dns_toast')));
} else {
dispatch(addSuccessToast('config_successfully_saved'));
dispatch(addSuccessToast(intl.getMessage('settings_notify_changes_saved')));
}
dispatch(setDnsConfigSuccess(config));

View File

@@ -3,6 +3,7 @@ import { apiClient } from '../api/Api';
import { redirectToCurrentProtocol } from '../helpers/helpers';
import { addErrorToast, addSuccessToast } from './toasts';
import intl from 'panel/common/intl';
export const getTlsStatusRequest = createAction('GET_TLS_STATUS_REQUEST');
export const getTlsStatusFailure = createAction('GET_TLS_STATUS_FAILURE');
@@ -57,7 +58,7 @@ export const setTlsConfig = (config: any) => async (dispatch: any, getState: any
}
dispatch(setTlsConfigSuccess(response));
dispatch(addSuccessToast('encryption_config_saved'));
dispatch(addSuccessToast(intl.getMessage('encryption_config_saved')));
} catch (error) {
dispatch(addErrorToast({ error }));
dispatch(setTlsConfigFailure());

View File

@@ -154,19 +154,10 @@ export const setFiltersConfigRequest = createAction('SET_FILTERS_CONFIG_REQUEST'
export const setFiltersConfigFailure = createAction('SET_FILTERS_CONFIG_FAILURE');
export const setFiltersConfigSuccess = createAction('SET_FILTERS_CONFIG_SUCCESS');
export const setFiltersConfig = (config: any) => async (dispatch: any, getState: any) => {
export const setFiltersConfig = (config: any) => async (dispatch: any) => {
dispatch(setFiltersConfigRequest());
try {
const { enabled } = config;
const prevEnabled = getState().filtering.enabled;
let successToastMessage = 'config_successfully_saved';
if (prevEnabled !== enabled) {
successToastMessage = enabled ? 'enabled_filtering_toast' : 'disabled_filtering_toast';
}
await apiClient.setFiltersConfig(config);
dispatch(addSuccessToast(successToastMessage));
dispatch(setFiltersConfigSuccess(config));
} catch (error) {
dispatch(addErrorToast({ error }));

View File

@@ -27,72 +27,83 @@ import { getTlsStatus } from './encryption';
import { apiClient } from '../api/Api';
import { addErrorToast, addNoticeToast, addSuccessToast } from './toasts';
import { getFilteringStatus, setRules } from './filtering';
import type { Dispatch } from 'redux';
import type { SettingsData } from '../initialState';
import intl from 'panel/common/intl';
export const toggleSettingStatus = createAction('SETTING_STATUS_TOGGLE');
export const toggleSettingStatus = createAction<{
settingKey: keyof typeof SETTINGS_NAMES;
value?: boolean | SafeSearchConfig;
}>('SETTING_STATUS_TOGGLE');
export const showSettingsFailure = createAction('SETTINGS_FAILURE_SHOW');
// Types for settings actions
type SafeSearchConfig = Record<string, boolean> & { enabled: boolean };
type ToggleSettingKey = keyof typeof SETTINGS_NAMES;
type SettingsItem = NonNullable<SettingsData['settingsList']>['safebrowsing'];
type InitSettingsItemInput = Partial<Pick<SettingsItem, 'enabled' | 'order' | 'subtitle' | 'title'>> &
Record<string, unknown>;
type InitSettingsArg = {
safebrowsing?: InitSettingsItemInput;
parental?: InitSettingsItemInput;
};
/**
*
* @param {*} settingKey = SETTINGS_NAMES
* @param {*} status: boolean | SafeSearchConfig
* @returns
*/
export const toggleSetting = (settingKey: any, status: any) => async (dispatch: any) => {
let successMessage = '';
try {
switch (settingKey) {
case SETTINGS_NAMES.safebrowsing:
if (status) {
successMessage = 'disabled_safe_browsing_toast';
await apiClient.disableSafebrowsing();
} else {
successMessage = 'enabled_safe_browsing_toast';
await apiClient.enableSafebrowsing();
}
dispatch(toggleSettingStatus({ settingKey }));
break;
case SETTINGS_NAMES.parental:
if (status) {
successMessage = 'disabled_parental_toast';
await apiClient.disableParentalControl();
} else {
successMessage = 'enabled_parental_toast';
await apiClient.enableParentalControl();
}
dispatch(toggleSettingStatus({ settingKey }));
break;
case SETTINGS_NAMES.safesearch:
successMessage = 'updated_save_search_toast';
await apiClient.updateSafesearch(status);
dispatch(toggleSettingStatus({ settingKey, value: status }));
break;
default:
break;
export const toggleSetting =
(settingKey: ToggleSettingKey, status: boolean | SafeSearchConfig) => async (dispatch: Dispatch) => {
try {
switch (settingKey) {
case SETTINGS_NAMES.safebrowsing:
if (status) {
await apiClient.disableSafebrowsing();
} else {
await apiClient.enableSafebrowsing();
}
dispatch(toggleSettingStatus({ settingKey }));
break;
case SETTINGS_NAMES.parental:
if (status) {
await apiClient.disableParentalControl();
} else {
await apiClient.enableParentalControl();
}
dispatch(toggleSettingStatus({ settingKey }));
break;
case SETTINGS_NAMES.safesearch:
await apiClient.updateSafesearch(status as SafeSearchConfig);
dispatch(toggleSettingStatus({ settingKey, value: status }));
break;
default:
break;
}
} catch (error) {
dispatch(addErrorToast({ error }));
}
dispatch(addSuccessToast(successMessage));
} catch (error) {
dispatch(addErrorToast({ error }));
}
};
};
export const initSettingsRequest = createAction('SETTINGS_INIT_REQUEST');
export const initSettingsFailure = createAction('SETTINGS_INIT_FAILURE');
export const initSettingsSuccess = createAction('SETTINGS_INIT_SUCCESS');
type SettingsSuccessList = {
safebrowsing: Record<string, unknown> & { enabled: boolean };
parental: Record<string, unknown> & { enabled: boolean };
safesearch: SafeSearchConfig;
};
export const initSettingsSuccess = createAction<{ settingsList: SettingsSuccessList }>('SETTINGS_INIT_SUCCESS');
export const initSettings =
(
settingsList = {
safebrowsing: {},
parental: {},
},
) =>
async (dispatch: any) => {
(settingsList: InitSettingsArg = { safebrowsing: {}, parental: {} }) =>
async (dispatch: Dispatch) => {
dispatch(initSettingsRequest());
try {
const safebrowsingStatus = await apiClient.getSafebrowsingStatus();
const parentalStatus = await apiClient.getParentalStatus();
const safesearchStatus = await apiClient.getSafesearchStatus();
const { safebrowsing, parental } = settingsList;
const safesearchStatus = (await apiClient.getSafesearchStatus()) as SafeSearchConfig;
const { safebrowsing = {}, parental = {} } = settingsList;
const newSettingsList = {
safebrowsing: {
...safebrowsing,
@@ -120,19 +131,21 @@ export const toggleProtectionSuccess = createAction('TOGGLE_PROTECTION_SUCCESS')
const getDisabledMessage = (time: any) => {
switch (time) {
case DISABLE_PROTECTION_TIMINGS.HALF_MINUTE:
return i18next.t('disable_notify_for_seconds', {
return intl.getMessage('disable_notify_for_seconds', {
count: msToSeconds(DISABLE_PROTECTION_TIMINGS.HALF_MINUTE),
});
case DISABLE_PROTECTION_TIMINGS.MINUTE:
return i18next.t('disable_notify_for_minutes', { count: msToMinutes(DISABLE_PROTECTION_TIMINGS.MINUTE) });
return intl.getMessage('disable_notify_for_minutes', {
count: msToMinutes(DISABLE_PROTECTION_TIMINGS.MINUTE),
});
case DISABLE_PROTECTION_TIMINGS.TEN_MINUTES:
return i18next.t('disable_notify_for_minutes', {
return intl.getMessage('disable_notify_for_minutes', {
count: msToMinutes(DISABLE_PROTECTION_TIMINGS.TEN_MINUTES),
});
case DISABLE_PROTECTION_TIMINGS.HOUR:
return i18next.t('disable_notify_for_hours', { count: msToHours(DISABLE_PROTECTION_TIMINGS.HOUR) });
return intl.getMessage('disable_notify_for_hours', { count: msToHours(DISABLE_PROTECTION_TIMINGS.HOUR) });
case DISABLE_PROTECTION_TIMINGS.TOMORROW:
return i18next.t('disable_notify_until_tomorrow');
return intl.getMessage('disable_notify_until_tomorrow');
default:
return 'disabled_protection';
}
@@ -143,7 +156,7 @@ export const toggleProtection =
async (dispatch: any) => {
dispatch(toggleProtectionRequest());
try {
const successMessage = status ? getDisabledMessage(time) : 'enabled_protection';
const successMessage = status ? getDisabledMessage(time) : intl.getMessage('enabled_protection');
await apiClient.setProtection({ enabled: !status, duration: time });
dispatch(addSuccessToast(successMessage));
dispatch(toggleProtectionSuccess({ disabledDuration: time }));
@@ -176,9 +189,9 @@ export const getVersion =
const currentVersion = dnsVersion === 'undefined' ? 0 : dnsVersion;
if (data && !areEqualVersions(currentVersion, data.new_version)) {
dispatch(addSuccessToast('updates_checked'));
dispatch(addSuccessToast(intl.getMessage('updates_checked')));
} else {
dispatch(addSuccessToast('updates_version_equal'));
dispatch(addSuccessToast(intl.getMessage('updates_version_equal')));
}
}
} catch (error) {
@@ -413,7 +426,7 @@ export const testUpstream =
});
if (testMessages.every((message) => message === 'OK' || message.startsWith('WARNING:'))) {
dispatch(addSuccessToast('dns_test_ok_toast'));
dispatch(addSuccessToast(intl.getMessage('dns_test_ok_toast')));
}
dispatch(testUpstreamSuccess());
@@ -572,7 +585,7 @@ export const findActiveDhcp = (selectedInterface: any) => async (dispatch: any,
});
dispatch(addErrorToast({ error: warning }));
} else {
dispatch(addSuccessToast('dhcp_not_found'));
dispatch(addSuccessToast(intl.getMessage('dhcp_not_found')));
}
} catch (error) {
dispatch(addErrorToast({ error }));
@@ -589,7 +602,7 @@ export const setDhcpConfig = (values: any) => async (dispatch: any) => {
try {
await apiClient.setDhcpConfig(values);
dispatch(setDhcpConfigSuccess(values));
dispatch(addSuccessToast('dhcp_config_saved'));
dispatch(addSuccessToast(intl.getMessage('dhcp_config_saved')));
} catch (error) {
dispatch(addErrorToast({ error }));
dispatch(setDhcpConfigFailure());
@@ -606,14 +619,14 @@ export const toggleDhcp = (values: any) => async (dispatch: any) => {
...values,
enabled: false,
};
let successMessage = 'disabled_dhcp';
let successMessage = intl.getMessage('disabled_dhcp');
if (!values.enabled) {
config = {
...values,
enabled: true,
};
successMessage = 'enabled_dhcp';
successMessage = intl.getMessage('enabled_dhcp');
}
try {
@@ -635,7 +648,7 @@ export const resetDhcp = () => async (dispatch: any) => {
try {
const status = await apiClient.resetDhcp();
dispatch(resetDhcpSuccess(status));
dispatch(addSuccessToast('dhcp_config_saved'));
dispatch(addSuccessToast(intl.getMessage('dhcp_config_saved')));
} catch (error) {
dispatch(addErrorToast({ error }));
dispatch(resetDhcpFailure());
@@ -651,7 +664,7 @@ export const resetDhcpLeases = () => async (dispatch: any) => {
try {
const status = await apiClient.resetDhcpLeases();
dispatch(resetDhcpLeasesSuccess(status));
dispatch(addSuccessToast('dhcp_reset_leases_success'));
dispatch(addSuccessToast(intl.getMessage('dhcp_reset_leases_success')));
} catch (error) {
dispatch(addErrorToast({ error }));
dispatch(resetDhcpLeasesFailure());
@@ -670,7 +683,7 @@ export const addStaticLease = (config: any) => async (dispatch: any) => {
const name = config.hostname || config.ip;
await apiClient.addStaticLease(config);
dispatch(addStaticLeaseSuccess(config));
dispatch(addSuccessToast(i18next.t('dhcp_lease_added', { key: name })));
dispatch(addSuccessToast(intl.getMessage('dhcp_lease_added', { key: name })));
dispatch(toggleLeaseModal());
dispatch(getDhcpStatus());
} catch (error) {
@@ -689,7 +702,7 @@ export const removeStaticLease = (config: any) => async (dispatch: any) => {
const name = config.hostname || config.ip;
await apiClient.removeStaticLease(config);
dispatch(removeStaticLeaseSuccess(config));
dispatch(addSuccessToast(i18next.t('dhcp_lease_deleted', { key: name })));
dispatch(addSuccessToast(intl.getMessage('dhcp_lease_deleted', { key: name })));
} catch (error) {
dispatch(addErrorToast({ error }));
dispatch(removeStaticLeaseFailure());
@@ -705,7 +718,7 @@ export const updateStaticLease = (config: any) => async (dispatch: any) => {
try {
await apiClient.updateStaticLease(config);
dispatch(updateStaticLeaseSuccess(config));
dispatch(addSuccessToast(i18next.t('dhcp_lease_updated', { key: config.hostname || config.ip })));
dispatch(addSuccessToast(intl.getMessage('dhcp_lease_updated', { key: config.hostname || config.ip })));
dispatch(toggleLeaseModal());
dispatch(getDhcpStatus());
} catch (error) {
@@ -734,15 +747,23 @@ export const toggleBlocking =
if (matchPreparedBlockingRule) {
await dispatch(setRules(userRules.replace(`${blockingRule}`, '')));
dispatch(addSuccessToast(i18next.t('rule_removed_from_custom_filtering_toast', { rule: blockingRule })));
dispatch(
addSuccessToast(intl.getMessage('rule_removed_from_custom_filtering_toast', { rule: blockingRule })),
);
} else if (!matchPreparedUnblockingRule) {
await dispatch(setRules(`${userRules}${lineEnding}${unblockingRule}\n`));
dispatch(addSuccessToast(i18next.t('rule_added_to_custom_filtering_toast', { rule: unblockingRule })));
dispatch(
addSuccessToast(intl.getMessage('rule_added_to_custom_filtering_toast', { rule: unblockingRule })),
);
} else if (matchPreparedUnblockingRule) {
dispatch(addSuccessToast(i18next.t('rule_added_to_custom_filtering_toast', { rule: unblockingRule })));
dispatch(
addSuccessToast(intl.getMessage('rule_added_to_custom_filtering_toast', { rule: unblockingRule })),
);
return;
} else if (!matchPreparedBlockingRule) {
dispatch(addSuccessToast(i18next.t('rule_removed_from_custom_filtering_toast', { rule: blockingRule })));
dispatch(
addSuccessToast(intl.getMessage('rule_removed_from_custom_filtering_toast', { rule: blockingRule })),
);
return;
}

View File

@@ -27,7 +27,7 @@ export const setStatsConfig = (config: any) => async (dispatch: any) => {
dispatch(setStatsConfigRequest());
try {
await apiClient.setStatsConfig(config);
dispatch(addSuccessToast('config_successfully_saved'));
dispatch(addSuccessToast('settings_notify_changes_saved'));
dispatch(setStatsConfigSuccess(config));
} catch (error) {
dispatch(addErrorToast({ error }));
@@ -74,7 +74,7 @@ export const resetStats = () => async (dispatch: any) => {
dispatch(getStatsRequest());
try {
await apiClient.resetStats();
dispatch(addSuccessToast('statistics_cleared'));
dispatch(addSuccessToast('settings_notify_statistics_cleared'));
dispatch(resetStatsSuccess());
} catch (error) {
dispatch(addErrorToast({ error }));

View File

@@ -3,6 +3,10 @@
align-items: center;
cursor: pointer;
gap: 8px;
&.disabled {
cursor: default;
}
}
.input {
@@ -45,11 +49,9 @@
position: absolute;
width: 24px;
height: 24px;
transition: color var(--t2);
color: var(--default-gray-icons);
}
.active {
transition: none;
color: var(--default-product-icon);
}

View File

@@ -27,7 +27,7 @@ export const Checkbox = ({
onClick,
plusStyle,
}: Props) => (
<label htmlFor={id} className={cn(s.checkbox, className)} onClick={onClick}>
<label htmlFor={id} className={cn(s.checkbox, { [s.disabled]: disabled }, className)} onClick={onClick}>
<input
id={id}
name={name}

View File

@@ -11,6 +11,7 @@ type Props<T> = {
handleChange: (e: T) => void;
value: T;
options: { text: string; value: T }[];
name?: string;
};
export const Radio = <T extends number | string | boolean = string>({
@@ -20,14 +21,19 @@ export const Radio = <T extends number | string | boolean = string>({
handleChange,
value,
options,
name,
}: Props<T>) => (
<div className={cn(s.wrap, wrapClass)}>
{options.map((o) => (
<label key={`${o.value}`} htmlFor={String(o.value)} className={cn(s.radio, className)}>
<label
key={`${o.value}`}
htmlFor={name ? `${name}-${String(o.value)}` : String(o.value)}
className={cn(s.radio, className)}>
<input
id={String(o.value)}
id={name ? `${name}-${String(o.value)}` : String(o.value)}
type="radio"
className={s.input}
name={name}
onChange={() => handleChange(o.value)}
checked={value === o.value}
disabled={disabled}

View File

@@ -1,4 +1,4 @@
import React, { ChangeEvent, ReactNode } from 'react';
import React, { ChangeEvent, ReactNode, forwardRef, ForwardedRef } from 'react';
import cn from 'clsx';
import s from './Switch.module.pcss';
@@ -10,48 +10,45 @@ type Props = {
labelClassName?: string;
className?: string;
wrapperClassName?: string;
handleChange: (e: ChangeEvent<HTMLInputElement>) => void;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
children?: ReactNode;
};
export const Switch = ({
id,
checked,
disabled,
children,
className,
labelClassName,
wrapperClassName,
handleChange,
}: Props) => {
const switchControls = (
<>
<input
id={id}
type="checkbox"
className={s.input}
onChange={handleChange}
checked={checked}
disabled={disabled}
/>
<div className={s.handler} />
{children && <div className={cn(s.label, labelClassName)}>{children}</div>}
</>
);
export const Switch = forwardRef(
(
{ id, checked, disabled, children, className, labelClassName, wrapperClassName, onChange }: Props,
ref: ForwardedRef<HTMLInputElement>,
) => {
const switchControls = (
<>
<input
id={id}
type="checkbox"
className={s.input}
onChange={onChange}
checked={checked}
disabled={disabled}
ref={ref}
/>
<div className={s.handler} />
{children && <div className={cn(s.label, labelClassName)}>{children}</div>}
</>
);
const getContent = () => {
if (wrapperClassName) {
return <div className={wrapperClassName}>{switchControls}</div>;
}
const getContent = () => {
if (wrapperClassName) {
return <div className={wrapperClassName}>{switchControls}</div>;
}
return switchControls;
};
return switchControls;
};
return (
<label htmlFor={id} className={cn(s.switch, className, { [s.disabled]: disabled })}>
{getContent()}
</label>
);
};
return (
<label htmlFor={id} className={cn(s.switch, className, { [s.disabled]: disabled })}>
{getContent()}
</label>
);
},
);
export default Switch;

View File

@@ -1,45 +1,55 @@
import React, { ComponentProps } from 'react';
import React, { ComponentProps, ReactNode, forwardRef } from 'react';
import cn from 'clsx';
import s from './styles.module.pcss';
type Props = ComponentProps<'textarea'> & {
label?: string;
label?: ReactNode;
size?: 'small' | 'large';
errorMessage?: string;
};
export const Textarea = ({
id,
label,
placeholder,
onChange,
value,
rows,
cols,
wrap,
className,
maxLength,
errorMessage,
disabled,
}: Props) => (
<div className={s.textareaWrapper}>
{label && (
<label className={s.label} htmlFor={id}>
{label}
</label>
)}
<textarea
className={cn(s.textarea, { [s.error]: !!errorMessage }, className)}
id={id}
placeholder={placeholder}
value={value}
cols={cols}
rows={rows}
onChange={onChange}
wrap={wrap}
maxLength={maxLength}
disabled={disabled}
/>
{errorMessage && <div className={s.errorMessage}>{errorMessage}</div>}
</div>
export const Textarea = forwardRef<HTMLTextAreaElement, Props>(
(
{
id,
label,
size,
placeholder,
onChange,
value,
rows,
cols,
wrap,
className,
maxLength,
errorMessage,
disabled,
...rest
}: Props,
ref,
) => (
<div className={s.textareaWrapper}>
{label && (
<label className={s.label} htmlFor={id}>
{label}
</label>
)}
<textarea
className={cn(s.textarea, s[size], { [s.error]: !!errorMessage }, className)}
id={id}
placeholder={placeholder}
value={value}
cols={cols}
rows={rows}
onChange={onChange}
wrap={wrap}
maxLength={maxLength}
disabled={disabled}
ref={ref}
{...rest}
/>
{errorMessage && <div className={s.errorMessage}>{errorMessage}</div>}
</div>
),
);

View File

@@ -41,6 +41,10 @@
box-shadow: 0 0 0 100px var(--pressed-page-background) inset;
}
}
&.large {
min-height: 200px;
}
}
.label {

View File

@@ -64,6 +64,10 @@ const intl = {
return translator.getMessage(key, values);
},
getPlural: (key: string, values?: any) => {
return translator.getPlural(key, values);
},
getUILanguage: () => currentLanguage,
getBaseMessage: (key: string) => {

View File

@@ -22,7 +22,7 @@ export const Breadcrumbs = ({ parentLinks, currentTitle }: Props) => (
<div key={path} className={s.link}>
<Link
to={path}
className={cn(theme.link.link, theme.link.no_decoration, theme.common.textOverflow)}
className={cn(theme.link.link, theme.link.noDecoration, theme.common.textOverflow)}
props={props}>
{title}
</Link>

View File

@@ -105,7 +105,7 @@ export const Footer = () => {
<a
key={name}
href={href}
className={cn(theme.link.link, theme.link.no_decoration)}
className={cn(theme.link.link, theme.link.noDecoration)}
target="_blank"
rel="noopener noreferrer">
{name}

View File

@@ -62,7 +62,7 @@ export const Menu = ({ headerMenu }: Props) => {
icon="settings"
items={[
{
label: intl.getMessage('general_settings_short'),
label: intl.getMessage('settings_general_short'),
path: Paths.SettingsPage,
routePath: RoutePath.SettingsPage,
},

View File

@@ -18,6 +18,7 @@ import { changeLanguage, getDnsStatus, getTimerStatus } from '../../actions';
import { RootState } from '../../initialState';
import s from './styles.module.pcss';
import Settings from 'panel/containers/Settings';
type RouteConfig = {
path: string;
@@ -25,7 +26,13 @@ type RouteConfig = {
exact: boolean;
};
const ROUTES: RouteConfig[] = [];
const ROUTES: RouteConfig[] = [
{
path: '/settings',
component: Settings,
exact: true,
},
];
const App = () => {
const dispatch = useDispatch();

View File

@@ -1,208 +0,0 @@
.title {
margin: 0 0 16px 0;
}
.subtitle {
margin-top: 40px;
margin-bottom: 16px;
}
.iconsContainer {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 16px;
}
.iconItem {
display: flex;
flex-direction: column;
align-items: center;
width: 100px;
padding: 12px;
border: 1px solid var(--default-item-divider);
border-radius: 4px;
}
.icon {
width: 24px;
height: 24px;
margin-bottom: 8px;
color: var(--default-gray-icons);
}
.iconName {
font-size: 12px;
text-align: center;
word-break: break-word;
}
.colorsContainer {
list-style: none;
margin: 8px 0 0 0;
padding: 0;
display: block;
}
.colorSwatch {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
padding: 6px 0;
border: none;
border-radius: 0;
margin-bottom: 0;
gap: 16px;
border-bottom: 1px solid var(--default-item-divider);
}
.colorBox {
width: 36px;
height: 18px;
border-radius: 3px;
border: 1px solid var(--default-item-divider);
margin-bottom: 0;
margin-right: 12px;
}
.colorName {
font-size: 12px;
margin: 0 12px 0 0;
text-align: left;
word-break: break-all;
min-width: 180px;
}
.colorValue {
min-width: 55px;
font-size: 12px;
color: var(--default-main-text);
text-align: left;
word-break: break-all;
}
.iconsContainer {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.iconItem {
display: flex;
flex-direction: column;
align-items: center;
width: 110px;
padding: 6px;
border: 1px solid var(--default-item-divider);
border-radius: 4px;
}
.icon {
width: 24px;
height: 24px;
margin-bottom: 4px;
color: var(--default-gray-icons);
}
.iconName {
font-size: 10px;
text-align: center;
word-break: break-word;
}
.buttonsContainer {
display: grid;
gap: 16px;
grid-template-columns: repeat(2, 1fr);
margin-top: 16px;
@media (min-width: 768px) {
grid-template-columns: repeat(3, 1fr);
}
}
.typographySection {
display: flex;
flex-direction: column;
gap: 16px;
margin: 24px 0;
}
.typographyBlock {
margin-bottom: 24px;
}
.typographyHeading {
font-weight: 600;
margin-bottom: 8px;
}
.typographyList {
display: flex;
flex-direction: column;
gap: 4px;
}
.selectExamples {
display: flex;
flex-direction: column;
gap: 24px;
margin-top: 16px;
}
.selectExample {
margin-bottom: 8px;
}
.exampleTitle {
font-size: 12px;
font-weight: 600;
margin-bottom: 8px;
}
.selectRow {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.selectItem {
min-width: 200px;
margin-bottom: 8px;
}
.selectLabel {
font-size: 12px;
margin-bottom: 4px;
color: var(--default-description-text);
}
.contolsList {
display: flex;
gap: 8px;
flex-direction: column;
}
.deprecationNotice {
padding: 24px;
border: 2px solid #ff9800;
border-radius: 8px;
background-color: #fff8e1;
margin: 24px 0;
}
.storyBookInstructions {
background-color: #f5f5f5;
padding: 16px 24px;
border-radius: 4px;
margin: 16px 0;
}
.storyBookInstructions code {
background-color: #e0e0e0;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
}

View File

@@ -0,0 +1,76 @@
import React, { useEffect, useRef } from 'react';
import { Controller, useForm } from 'react-hook-form';
import intl from 'panel/common/intl';
import theme from 'panel/lib/theme';
import { SwitchGroup } from '../SettingsGroup';
import { RoutePath } from 'panel/components/Routes/Paths';
import { Link } from 'panel/common/ui/Link';
export type FormValues = {
enabled: boolean;
interval: number;
};
type Props = {
initialValues: FormValues;
setFiltersConfig: (values: FormValues) => void;
processing: boolean;
};
export const FiltersConfig = ({ initialValues, setFiltersConfig, processing }: Props) => {
const { watch, control, setValue } = useForm({
mode: 'onBlur',
defaultValues: initialValues,
});
const enabled = watch('enabled');
useEffect(() => {
setFiltersConfig({ ...initialValues, enabled });
}, [enabled]);
return (
<div>
<Controller
name="enabled"
control={control}
render={() => (
<SwitchGroup
title={intl.getMessage('settings_filter_requests')}
description={intl.getMessage('settings_filter_requests_desc', {
a: (text: string) => (
<Link
to={RoutePath.DnsBlocklists}
className={theme.link.link}
onClick={(e) => e.stopPropagation()}>
{text}
</Link>
),
b: (text: string) => (
<Link
to={RoutePath.DnsAllowlists}
className={theme.link.link}
onClick={(e) => e.stopPropagation()}>
{text}
</Link>
),
c: (text: string) => (
<Link
to={RoutePath.UserRules}
className={theme.link.link}
onClick={(e) => e.stopPropagation()}>
{text}
</Link>
),
})}
disabled={processing}
onChange={(e) => setValue('enabled', e.target.checked)}
id="filters_enabled"
checked={enabled}
/>
)}
/>
</div>
);
};

View File

@@ -0,0 +1,88 @@
import React from 'react';
import { Controller, type Control, type UseFormSetValue } from 'react-hook-form';
import cn from 'clsx';
import { Textarea } from 'panel/common/controls/Textarea';
import { SwitchGroup } from '../SettingsGroup';
import intl from 'panel/common/intl';
import { trimLinesAndRemoveEmpty } from 'panel/helpers/helpers';
import s from './styles.module.pcss';
import { Dropdown } from 'panel/common/ui/Dropdown';
import theme from 'panel/lib/theme';
import { Icon } from 'panel/common/ui/Icon';
type Props = {
control: Control<any>;
processing: boolean;
ignoreEnabled: boolean;
setValue: UseFormSetValue<any>;
switchId: string;
textareaId: string;
};
export const IgnoredDomains = ({ control, processing, ignoreEnabled, setValue, switchId, textareaId }: Props) => {
return (
<SwitchGroup
id={switchId}
title={intl.getMessage('ignore_domains_title')}
description={intl.getMessage('ignore_domains_desc_query')}
checked={ignoreEnabled}
onChange={(e) => setValue('ignore_enabled', e.target.checked)}
disabled={processing}>
<Controller
name={'ignored'}
control={control}
render={({ field, fieldState }) => (
<Textarea
{...field}
id={textareaId}
label={
<div className={s.label}>
{intl.getMessage('settings_domain_names')}
<Dropdown
trigger="hover"
menu={
<div className={cn(theme.dropdown.menu, s.dropdownMenu)}>
<div className={s.dropdownTitle}>
{intl.getMessage('settings_tooltip_domain_names')}
</div>
<div className={s.dropdownText}>
<strong>{intl.getMessage('settings_tooltip_examples')}</strong>
<div>example.com</div>
<div>*.example.com</div>
<div>||example.com^</div>
</div>
</div>
}
className={s.dropdown}
position="bottomLeft"
noIcon>
<div className={s.dropdownTrigger}>
<Icon icon="faq" className={s.icon} />
</div>
</Dropdown>
<a
href="https://link.adtidy.org/forward.html?action=dns_kb_filtering_syntax&from=ui&app=home"
target="_blank"
rel="noopener noreferrer"
className={cn(s.link, theme.link.link, theme.link.noDecoration)}>
{intl.getMessage('settings_rule_syntax')}
</a>
</div>
}
placeholder={'example.com\n*.example.com\n||example.com^'}
size="large"
disabled={processing || !ignoreEnabled}
errorMessage={fieldState.error?.message}
onBlur={(e) => {
const trimmed = trimLinesAndRemoveEmpty(e.target.value);
setValue('ignored', trimmed);
}}
/>
)}
/>
</SwitchGroup>
);
};

View File

@@ -0,0 +1 @@
export { IgnoredDomains } from './IgnoredDomains';

View File

@@ -0,0 +1,17 @@
.link {
margin-left: auto;
}
.label {
display: flex;
align-items: center;
gap: 8px;
}
.dropdownMenu {
padding: 16px;
}
.dropdownTitle {
margin-bottom: 16px;
}

View File

@@ -0,0 +1,181 @@
import React, { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import intl from 'panel/common/intl';
import { toNumber } from 'panel/helpers/form';
import { Input } from 'panel/common/controls/Input';
import { Button } from 'panel/common/ui/Button';
import theme from 'panel/lib/theme';
import { QUERY_LOG_INTERVALS_DAYS, HOUR, DAY, RETENTION_CUSTOM, RETENTION_RANGE } from '../../../helpers/constants';
import { RadioGroup } from '../SettingsGroup/RadioGroup';
import { SwitchGroup } from '../SettingsGroup';
import { IgnoredDomains } from '../IgnoredDomains';
const getIntervalTitle = (intervalDays: number) => {
switch (intervalDays) {
case RETENTION_CUSTOM:
return intl.getMessage('settings_custom');
case 6 * HOUR:
return intl.getPlural('settings_hours', 6);
case DAY:
return intl.getPlural('settings_hours', 24);
default:
return intl.getPlural('settings_days', intervalDays / DAY);
}
};
export type FormValues = {
enabled: boolean;
anonymize_client_ip: boolean;
interval: number;
customInterval?: number | null;
ignored: string;
ignore_enabled: boolean;
};
type Props = {
initialValues: Partial<FormValues>;
processing: boolean;
processingReset: boolean;
onSubmit: (values: FormValues) => void;
onReset: () => void;
};
export const Form = ({ initialValues, processing, processingReset, onSubmit, onReset }: Props) => {
const {
handleSubmit,
watch,
setValue,
control,
formState: { isSubmitting },
} = useForm<FormValues>({
mode: 'onBlur',
defaultValues: {
enabled: initialValues.enabled || false,
anonymize_client_ip: initialValues.anonymize_client_ip || false,
interval: initialValues.interval || DAY,
customInterval: initialValues.customInterval ?? undefined,
ignored: initialValues.ignored || '',
ignore_enabled: initialValues.ignore_enabled || true,
},
});
const intervalValue = watch('interval');
const customIntervalValue = watch('customInterval');
const ignoreEnabled = watch('ignore_enabled');
useEffect(() => {
if (QUERY_LOG_INTERVALS_DAYS.includes(intervalValue)) {
setValue('customInterval', undefined);
}
}, [intervalValue]);
const onSubmitForm = (data: FormValues) => {
onSubmit(data);
};
const disableSubmit = isSubmitting || processing || (intervalValue === RETENTION_CUSTOM && !customIntervalValue);
return (
<form onSubmit={handleSubmit(onSubmitForm)}>
<Controller
name="enabled"
control={control}
render={({ field }) => (
<SwitchGroup
id="logs_enabled"
title={intl.getMessage('settings_log_dns_requests')}
checked={field.value}
onChange={field.onChange}
disabled={processing}
/>
)}
/>
<Controller
name="anonymize_client_ip"
control={control}
render={({ field }) => (
<SwitchGroup
id="logs_anonymize_client_ip"
title={intl.getMessage('settings_anonymize_client_ip')}
description={intl.getMessage('settings_anonymize_client_ip_desc')}
checked={field.value}
onChange={field.onChange}
disabled={processing}
/>
)}
/>
<RadioGroup
title={intl.getMessage('query_log_retention')}
disabled={processing}
value={intervalValue}
onChange={(val) => setValue('interval', Number(val))}
name="logs-interval"
options={[
{ text: getIntervalTitle(RETENTION_CUSTOM), value: RETENTION_CUSTOM },
...QUERY_LOG_INTERVALS_DAYS.map((interval) => ({
text: getIntervalTitle(interval),
value: interval,
})),
]}>
<Controller
name="customInterval"
control={control}
render={({ field, fieldState }) => (
<div className={theme.form.input}>
<Input
id="logs_config_custom_interval"
label={intl.getMessage('settings_log_rotation_hours')}
placeholder={intl.getMessage('settings_rotation_placeholder')}
value={field.value ?? ''}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
disabled={processing || QUERY_LOG_INTERVALS_DAYS.includes(intervalValue)}
errorMessage={fieldState.error?.message}
min={RETENTION_RANGE.MIN}
max={RETENTION_RANGE.MAX}
/>
</div>
)}
/>
</RadioGroup>
<IgnoredDomains
control={control}
processing={processing}
ignoreEnabled={ignoreEnabled}
setValue={setValue}
switchId="logs_config_ignored_enabled"
textareaId="logs_config_ignored"
/>
<div className={theme.form.buttonGroup}>
<Button
type="submit"
data-testid="logs_config_save"
variant="primary"
size="small"
disabled={disableSubmit}
className={theme.form.button}>
{intl.getMessage('save')}
</Button>
<Button
type="button"
data-testid="logs_config_clear"
variant="secondary"
size="small"
onClick={onReset}
disabled={processingReset}
className={theme.form.button}>
{intl.getMessage('clear_query_log')}
</Button>
</div>
</form>
);
};

View File

@@ -0,0 +1,119 @@
import React, { useState } from 'react';
import { Form, FormValues } from './Form';
import { ConfirmDialog } from 'panel/common/ui/ConfirmDialog';
import intl from 'panel/common/intl';
import { HOUR } from 'panel/helpers/constants';
import { formatIntervalText } from 'panel/components/Settings/helpers';
export type LogsConfigPayload = {
enabled: boolean;
anonymize_client_ip: boolean;
ignore_enabled: boolean;
ignored: string[];
interval: number;
};
type Props = {
interval: number;
customInterval?: number;
enabled: boolean;
anonymize_client_ip: boolean;
processing: boolean;
ignored: string[];
processingClear: boolean;
setLogsConfig: (values: LogsConfigPayload) => unknown;
clearLogs: () => void;
};
export const LogsConfig = ({
interval: prevInterval,
customInterval,
enabled,
anonymize_client_ip,
processing,
processingClear,
ignored,
setLogsConfig,
clearLogs,
}: Props) => {
const [openConfirmDialog, setOpenConfirmDialog] = useState(false);
const [confirmConfig, setConfirmConfig] = useState<LogsConfigPayload | null>(null);
const handleClear = () => {
setOpenConfirmDialog(true);
};
const handleClose = () => {
setOpenConfirmDialog(false);
};
const handleClearConfirm = () => {
clearLogs();
handleClose();
};
const handleFormSubmit = (values: FormValues) => {
const { interval, customInterval, ...rest } = values;
const newInterval = customInterval ? customInterval * HOUR : interval;
const data: LogsConfigPayload = {
...rest,
ignored: values.ignored ? values.ignored.split('\n') : [],
interval: newInterval,
};
if (newInterval < prevInterval) {
setConfirmConfig(data);
return;
}
setLogsConfig(data);
};
return (
<>
<Form
initialValues={{
enabled,
interval: prevInterval,
customInterval,
anonymize_client_ip,
ignored: ignored?.join('\n'),
}}
processing={processing}
processingReset={processingClear}
onSubmit={handleFormSubmit}
onReset={handleClear}
/>
{confirmConfig && (
<ConfirmDialog
onClose={() => setConfirmConfig(null)}
onConfirm={() => {
setLogsConfig(confirmConfig);
setConfirmConfig(null);
}}
buttonText={intl.getMessage('settings_yes_decrease')}
cancelText={intl.getMessage('cancel')}
title={intl.getMessage('settings_confirm_decrease_log_rotation_interval')}
text={intl.getMessage('settings_confirm_decrease_log_rotation_interval_desc', {
value: formatIntervalText(confirmConfig.interval),
})}
buttonVariant="danger"
/>
)}
{openConfirmDialog && (
<ConfirmDialog
onClose={handleClose}
onConfirm={handleClearConfirm}
buttonText={intl.getMessage('settings_yes_clear')}
cancelText={intl.getMessage('cancel')}
title={intl.getMessage('settings_confirm_clear_query_log')}
text={intl.getMessage('settings_confirm_clear_query_log_desc')}
buttonVariant="danger"
/>
)}
</>
);
};

View File

@@ -0,0 +1 @@
export { LogsConfig } from './LogsConfig';

View File

@@ -0,0 +1,205 @@
import React, { useEffect } from 'react';
import cn from 'clsx';
import intl from 'panel/common/intl';
import { Checkbox } from 'panel/common/controls/Checkbox';
import { Loader } from 'panel/common/ui/Loader';
import theme from 'panel/lib/theme';
import { StatsConfig } from './StatsConfig/StatsConfig';
import { LogsConfig } from './LogsConfig';
import { FiltersConfig } from './FiltersConfig';
import { getObjectKeysSorted, captitalizeWords } from '../../helpers/helpers';
import { SettingsData, StatsData, QueryLogsData, FilteringData } from '../../initialState';
import type { StatsConfigPayload } from './StatsConfig/StatsConfig';
import type { LogsConfigPayload } from './LogsConfig/LogsConfig';
import type { FormValues as FiltersFormValues } from './FiltersConfig';
import { SwitchGroup } from './SettingsGroup';
import s from './styles.module.pcss';
const ORDER_KEY = 'order';
const SETTINGS = {
safebrowsing: {
enabled: false,
title: intl.getMessage('settings_browsing_security'),
subtitle: intl.getMessage('settings_browsing_security_desc'),
testId: 'safebrowsing',
[ORDER_KEY]: 0,
},
parental: {
enabled: false,
title: intl.getMessage('settings_parental_control'),
subtitle: intl.getMessage('settings_parental_control_desc'),
testId: 'parental',
[ORDER_KEY]: 1,
},
};
type InitSettingsArg = typeof SETTINGS;
type ToggleSettingArgKey = keyof typeof SETTINGS | 'safesearch';
type ToggleSettingArgValue = boolean | Record<string, boolean>;
type Props = {
settings: SettingsData;
stats: StatsData;
queryLogs: QueryLogsData;
filtering: FilteringData;
initSettings: (settings: InitSettingsArg) => void;
toggleSetting: (key: ToggleSettingArgKey, value: ToggleSettingArgValue) => void;
getStatsConfig: () => void;
setStatsConfig: (config: StatsConfigPayload) => void;
resetStats: () => void;
setFiltersConfig: (values: FiltersFormValues) => void;
getFilteringStatus: () => void;
getLogsConfig?: () => void;
setLogsConfig?: (values: LogsConfigPayload) => void;
clearLogs?: () => void;
};
export const Settings = ({
initSettings,
getStatsConfig,
getLogsConfig,
getFilteringStatus,
settings,
toggleSetting,
setStatsConfig,
resetStats,
stats,
queryLogs,
setLogsConfig,
clearLogs,
filtering,
setFiltersConfig,
}: Props) => {
useEffect(() => {
initSettings(SETTINGS);
getStatsConfig();
getFilteringStatus();
if (getLogsConfig) {
getLogsConfig();
}
}, []);
const renderSettings = (settingsList?: SettingsData['settingsList']) =>
settingsList
? getObjectKeysSorted(SETTINGS, ORDER_KEY).map((key: keyof typeof SETTINGS) => {
const setting = settingsList[key];
const { enabled, title, subtitle } = setting;
return (
<div key={key}>
<SwitchGroup
title={title}
description={subtitle}
id={String(key)}
checked={enabled}
onChange={(checked) => toggleSetting(key as ToggleSettingArgKey, !checked)}
/>
</div>
);
})
: null;
const renderSafeSearch = () => {
const safesearch = settings.settingsList?.safesearch;
if (!safesearch) {
return null;
}
const { enabled, ...searches } = safesearch;
return (
<SwitchGroup
id="safesearch"
title={intl.getMessage('settings_safe_search')}
description={intl.getMessage('settings_safe_search_desc')}
checked={enabled}
onChange={(e) => toggleSetting('safesearch', { ...safesearch, enabled: e.target.checked })}>
<div>
{Object.keys(searches).map((searchKey) => (
<div key={searchKey} className={s.checkbox}>
<Checkbox
id={searchKey}
checked={searches[searchKey]}
disabled={!enabled}
onChange={(e) => {
toggleSetting('safesearch', { ...safesearch, [searchKey]: e.target.checked });
}}>
{captitalizeWords(searchKey)}
</Checkbox>
</div>
))}
</div>
</SwitchGroup>
);
};
const isDataReady = !settings.processing && !stats.processingGetConfig && !queryLogs.processingGetConfig;
return (
<div className={theme.layout.container}>
<h1 className={cn(theme.layout.title, theme.title.h4, theme.title.h3_tablet)}>
{intl.getMessage('general_settings')}
</h1>
{!isDataReady && <Loader />}
{isDataReady && (
<>
<h2 className={cn(theme.layout.subtitle, theme.title.h5, theme.title.h4_tablet)}>
{intl.getMessage('settings_filter_requests')}
</h2>
<FiltersConfig
initialValues={{
interval: filtering.interval,
enabled: filtering.enabled,
}}
processing={filtering.processingSetConfig}
setFiltersConfig={setFiltersConfig}
/>
{renderSettings(settings.settingsList)}
{renderSafeSearch()}
<h2 className={cn(theme.layout.subtitle, theme.title.h5, theme.title.h4_tablet)}>
{intl.getMessage('query_log')}
</h2>
<LogsConfig
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}
setLogsConfig={setLogsConfig}
clearLogs={clearLogs}
/>
<h2 className={cn(theme.layout.subtitle, theme.title.h5, theme.title.h4_tablet)}>
{intl.getMessage('settings_statistics')}
</h2>
<StatsConfig
interval={stats.interval}
customInterval={stats.customInterval}
ignored={stats.ignored}
enabled={stats.enabled}
processing={stats.processingSetConfig}
processingReset={stats.processingReset}
setStatsConfig={setStatsConfig}
resetStats={resetStats}
/>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,49 @@
import React, { ReactNode } from 'react';
import cn from 'clsx';
import { Radio } from 'panel/common/controls/Radio';
import s from './styles.module.pcss';
type Option<T> = { text: string; value: T };
type Props<T> = {
title: string;
description?: string;
disabled?: boolean;
value: T;
options: Option<T>[];
onChange: (value: T) => void;
className?: string;
children?: ReactNode;
name?: string;
};
export const RadioGroup = <T extends number | string | boolean>({
title,
description,
disabled,
value,
options,
onChange,
className,
children,
name,
}: Props<T>) => {
return (
<div className={cn(s.switch, className)}>
<div className={s.row}>
<div className={s.text}>
<div className={s.title}>{title}</div>
{description && <div className={s.desc}>{description}</div>}
</div>
<div className={s.input} />
</div>
<div className={s.content}>
<Radio<T> disabled={disabled} value={value} options={options} handleChange={onChange} name={name} />
{children}
</div>
</div>
);
};

View File

@@ -0,0 +1,45 @@
import React, { ReactNode, useRef } from 'react';
import cn from 'clsx';
import { Switch } from 'panel/common/controls/Switch';
import s from './styles.module.pcss';
type Props = {
title: string;
description?: string;
id: string;
className?: string;
checked: boolean;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
disabled?: boolean;
children?: ReactNode;
};
export const SwitchGroup = ({ title, description, id, className, checked, onChange, disabled, children }: Props) => {
const inputRef = useRef<HTMLInputElement>(null);
const handleRowClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (disabled || !inputRef.current) {
return;
}
inputRef.current?.click();
};
return (
<div className={cn(s.switch, className)}>
<div className={s.row} onClick={handleRowClick}>
<div className={s.text}>
<div className={s.title}>{title}</div>
{description && <div className={s.desc}>{description}</div>}
</div>
<div className={s.input} onClick={(e) => e.stopPropagation()}>
<Switch id={id} checked={checked} onChange={onChange} disabled={disabled} ref={inputRef} />
</div>
</div>
{children && <div className={s.content}>{children}</div>}
</div>
);
};

View File

@@ -0,0 +1,2 @@
export { RadioGroup } from './RadioGroup';
export { SwitchGroup } from './SwitchGroup';

View File

@@ -0,0 +1,44 @@
.switch {
width: 100%;
padding: 8px 16px;
transition: background-color var(--t2);
border-radius: 8px;
&:hover,
&:focus {
background-color: var(--fills-backgrounds-page-background-additional);
}
}
.row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
cursor: pointer;
@media (min-width: 1024px) {
gap: 40px;
}
}
.text {
padding: 8px 0;
}
.title {
font-size: 16px;
font-weight: var(--weight-semi-bold);
line-height: 24px;
color: var(--default-main-text);
}
.desc {
font-size: 14px;
color: var(--default-description-text);
}
.input,
.content {
padding: 8px 0;
}

View File

@@ -0,0 +1,168 @@
import React, { useEffect } from 'react';
import i18next from 'i18next';
import { Controller, useForm } from 'react-hook-form';
import { Input } from 'panel/common/controls/Input';
import { Button } from 'panel/common/ui/Button';
import intl from 'panel/common/intl';
import theme from 'panel/lib/theme';
import { STATS_INTERVALS_DAYS, DAY, RETENTION_CUSTOM, RETENTION_RANGE } from '../../../helpers/constants';
import { toNumber } from '../../../helpers/form';
import { RadioGroup, SwitchGroup } from '../SettingsGroup';
import { IgnoredDomains } from '../IgnoredDomains';
const getIntervalTitle = (interval: any) => {
switch (interval) {
case RETENTION_CUSTOM:
return i18next.t('settings_custom');
case DAY:
return i18next.t('interval_24_hour');
default:
return i18next.t('interval_days', { count: interval / DAY });
}
};
export type FormValues = {
enabled: boolean;
interval: number;
customInterval?: number | null;
ignored: string;
ignore_enabled: boolean;
};
const defaultFormValues = {
enabled: false,
interval: DAY,
customInterval: null,
ignored: '',
ignore_enabled: false,
};
type Props = {
initialValues: FormValues;
processing: boolean;
processingReset: boolean;
onSubmit: (values: FormValues) => void;
onReset: () => void;
};
export const Form = ({ initialValues, processing, processingReset, onSubmit, onReset }: Props) => {
const {
handleSubmit,
watch,
setValue,
control,
formState: { isSubmitting },
} = useForm<FormValues>({
mode: 'onBlur',
defaultValues: {
...defaultFormValues,
...initialValues,
},
});
const intervalValue = watch('interval');
const customIntervalValue = watch('customInterval');
const ignoreEnabled = watch('ignore_enabled');
useEffect(() => {
if (STATS_INTERVALS_DAYS.includes(intervalValue)) {
setValue('customInterval', null);
}
}, [intervalValue]);
const onSubmitForm = (data: FormValues) => {
onSubmit(data);
};
const disableSubmit = isSubmitting || processing || (intervalValue === RETENTION_CUSTOM && !customIntervalValue);
return (
<form onSubmit={handleSubmit(onSubmitForm)}>
<Controller
name="enabled"
control={control}
render={({ field }) => (
<SwitchGroup
checked={field.value}
onChange={field.onChange}
id="stats_config_enabled"
title={intl.getMessage('settings_statistics')}
description={intl.getMessage('settings_statistics_desc')}
disabled={processing}
/>
)}
/>
<RadioGroup
title={intl.getMessage('settings_statistics_retention')}
disabled={processing}
value={intervalValue}
onChange={(val) => setValue('interval', Number(val))}
name="stats-interval"
options={[
{ text: getIntervalTitle(RETENTION_CUSTOM), value: RETENTION_CUSTOM },
...STATS_INTERVALS_DAYS.map((interval) => ({
text: getIntervalTitle(interval),
value: interval,
})),
]}>
<Controller
name="customInterval"
control={control}
render={({ field, fieldState }) => (
<div className={theme.form.input}>
<Input
id="stats_config_custom_interval"
label={intl.getMessage('settings_statistics_retention_hours')}
placeholder={intl.getMessage('settings_rotation_placeholder')}
value={field.value ?? ''}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
disabled={processing || STATS_INTERVALS_DAYS.includes(intervalValue)}
errorMessage={fieldState.error?.message}
min={RETENTION_RANGE.MIN}
max={RETENTION_RANGE.MAX}
/>
</div>
)}
/>
</RadioGroup>
<IgnoredDomains
control={control}
processing={processing}
ignoreEnabled={ignoreEnabled}
setValue={setValue}
switchId="stats_config_ignored_enabled"
textareaId="stats_config_ignored"
/>
<div className={theme.form.buttonGroup}>
<Button
type="submit"
id="stats_config_save"
variant="primary"
size="small"
disabled={disableSubmit}
className={theme.form.button}>
{intl.getMessage('save')}
</Button>
<Button
type="button"
id="stats_config_clear"
onClick={onReset}
variant="secondary"
size="small"
disabled={processingReset}
className={theme.form.button}>
{intl.getMessage('settings_statistics_clear')}
</Button>
</div>
</form>
);
};

View File

@@ -0,0 +1,115 @@
import React, { useState } from 'react';
import { Form, FormValues } from './Form';
import { ConfirmDialog } from 'panel/common/ui/ConfirmDialog';
import intl from 'panel/common/intl';
import { HOUR } from 'panel/helpers/constants';
import { formatIntervalText } from 'panel/components/Settings/helpers';
export type StatsConfigPayload = {
enabled: boolean;
ignored: string[];
interval: number;
};
type Props = {
interval: number;
customInterval?: number;
ignored: string[];
enabled: boolean;
processing: boolean;
processingReset: boolean;
setStatsConfig: (config: StatsConfigPayload) => void;
resetStats: () => void;
};
export const StatsConfig = ({
interval: prevInterval,
customInterval,
ignored,
enabled,
processing,
processingReset,
setStatsConfig,
resetStats,
}: Props) => {
const [openClearDialog, setOpenClearDialog] = useState(false);
const [confirmConfig, setConfirmConfig] = useState<StatsConfigPayload | null>(null);
const handleClear = () => {
setOpenClearDialog(true);
};
const handleClose = () => {
setOpenClearDialog(false);
};
const handleClearConfirm = () => {
resetStats();
handleClose();
};
const handleFormSubmit = (values: FormValues) => {
const { interval, customInterval, enabled, ignored } = values;
const newInterval = customInterval ? customInterval * HOUR : interval;
const data: StatsConfigPayload = {
enabled,
interval: newInterval,
ignored: ignored ? ignored.split('\n') : [],
};
if (newInterval < prevInterval) {
setConfirmConfig(data);
return;
}
setStatsConfig(data);
};
return (
<>
<Form
initialValues={{
interval: prevInterval,
customInterval,
enabled,
ignored: ignored.join('\n'),
ignore_enabled: false,
}}
processing={processing}
processingReset={processingReset}
onSubmit={handleFormSubmit}
onReset={handleClear}
/>
{confirmConfig && (
<ConfirmDialog
onClose={() => setConfirmConfig(null)}
onConfirm={() => {
setStatsConfig(confirmConfig);
setConfirmConfig(null);
}}
buttonText={intl.getMessage('settings_yes_decrease')}
cancelText={intl.getMessage('cancel')}
title={intl.getMessage('settings_confirm_decrease_stats_rotation_interval')}
text={intl.getMessage('settings_confirm_decrease_stats_rotation_interval_desc', {
value: formatIntervalText(confirmConfig.interval),
})}
buttonVariant="danger"
/>
)}
{openClearDialog && (
<ConfirmDialog
onClose={handleClose}
onConfirm={handleClearConfirm}
buttonText={intl.getMessage('settings_yes_clear')}
cancelText={intl.getMessage('cancel')}
title={intl.getMessage('settings_confirm_clear_statistics')}
text={intl.getMessage('settings_confirm_clear_statistics_desc')}
buttonVariant="danger"
/>
)}
</>
);
};

View File

@@ -0,0 +1 @@
export { StatsConfig } from './StatsConfig';

View File

@@ -0,0 +1,15 @@
import intl from 'panel/common/intl';
import { HOUR, DAY } from 'panel/helpers/constants';
export const formatIntervalText = (intervalMs: number) => {
if (intervalMs === 6 * HOUR) {
return intl.getPlural('settings_hours', 6);
}
if (intervalMs === DAY) {
return intl.getPlural('settings_hours', 24);
}
if (intervalMs % DAY === 0) {
return intl.getPlural('settings_days', intervalMs / DAY);
}
return intl.getPlural('settings_hours', Math.floor(intervalMs / HOUR));
};

View File

@@ -0,0 +1 @@
export { Settings } from './Settings';

View File

@@ -0,0 +1,8 @@
.checkbox {
display: flex;
align-items: center;
padding: 8px 0;
}
.buttons {
}

View File

@@ -1,78 +0,0 @@
.toasts {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 105;
width: 345px;
}
@media (max-width: 425px) {
.toasts {
right: 0;
width: 100%;
}
}
.toast {
display: flex;
align-items: flex-start;
margin-bottom: 12px;
padding: 16px;
font-weight: 600;
color: #ffffff;
border-radius: 4px;
background-color: rgba(236, 53, 53, 0.75);
}
.toast--success {
background-color: rgba(90, 173, 99, 0.75);
}
.toast:last-child {
margin-bottom: 0;
}
.toast__content {
flex: 1 1 auto;
margin: 0 12px 0 0;
text-overflow: ellipsis;
overflow: hidden;
}
.toast__content a {
font-weight: 600;
color: #fff;
text-decoration: underline;
}
.toast__dismiss {
display: block;
flex: 0 0 auto;
padding: 0;
background: transparent;
border: 0;
cursor: pointer;
}
.toast__dismiss:hover,
.toast__dismiss:focus {
outline: none;
}
.toast-enter {
opacity: 0.01;
}
.toast-enter-active {
opacity: 1;
transition: all 0.3s ease-out;
}
.toast-exit {
opacity: 1;
}
.toast-exit-active {
opacity: 0.01;
transition: all 0.3s ease-out;
}

View File

@@ -0,0 +1,70 @@
.toasts {
position: fixed;
right: 16px;
width: 100%;
max-width: 345px;
bottom: 24px;
z-index: 105;
@media (min-width: 768px) {
right: 24px;
width: 345px;
}
}
.toast {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 12px;
padding: 16px;
font-weight: var(--weight-regular);
color: var(--default-main-text);
background-color: var(--default-page-background);
border-radius: 8px;
box-shadow:
2px 2px 4px 0 rgba(0, 0, 0, 0.1),
4px 4px 8px 0 rgba(0, 0, 0, 0.2);
&:last-child {
margin-bottom: 0;
}
&__content {
flex: 1 1 auto;
margin: 0 12px 0 0;
text-overflow: ellipsis;
overflow: hidden;
}
&__icon {
width: 24px;
height: 24px;
&_success {
color: var(--default-product-icon);
}
&_error {
color: var(--default-error-icon);
}
}
}
.toast-enter {
opacity: 0.01;
}
.toast-enter-active {
opacity: 1;
transition: all 0.3s ease-out;
}
.toast-exit {
opacity: 1;
}
.toast-exit-active {
opacity: 0.01;
transition: all 0.3s ease-out;
}

View File

@@ -4,6 +4,7 @@ import { useDispatch } from 'react-redux';
import { TOAST_TIMEOUTS } from '../../helpers/constants';
import { removeToast } from '../../actions';
import { Icon } from 'panel/common/ui/Icon';
interface ToastProps {
id: string;
@@ -30,28 +31,12 @@ const Toast = ({ id, message, type, options }: ToastProps) => {
}, []);
return (
<div
className={`toast toast--${type}`}
onMouseOver={clearRemoveToastTimeout}
onMouseOut={setRemoveToastTimeout}>
<p className="toast__content">
<div className="toast" onMouseOver={clearRemoveToastTimeout} onMouseOut={setRemoveToastTimeout}>
<Icon icon={type === 'success' ? 'check' : 'cross'} className={`toast__icon toast__icon_${type}`} />
<div className="toast__content">
<Trans i18nKey={message} {...options} />
</p>
<button className="toast__dismiss" onClick={removeCurrentToast}>
<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>
</div>
);
};

View File

@@ -5,7 +5,7 @@ import { CSSTransition, TransitionGroup } from 'react-transition-group';
import { TOAST_TRANSITION_TIMEOUT } from '../../helpers/constants';
import Toast from './Toast';
import './Toast.css';
import './Toast.pcss';
import { RootState } from '../../initialState';
const Toasts = () => {

View File

@@ -0,0 +1,155 @@
.card-header {
align-items: center;
justify-content: space-between;
padding: 0.6rem 1.5rem;
flex-shrink: 0;
}
.card-subtitle {
margin: 4px 0;
line-height: 1.4;
}
.card-table-overflow {
overflow-y: auto;
max-height: 100%;
}
.card-table-overflow--limited {
overflow-y: auto;
max-height: 17.5rem;
}
.dashboard .card-table {
overflow: hidden;
}
.dashboard .card-table-overflow--limited {
max-height: 292px;
}
.dashboard .ReactTable .rt-tr-group {
min-height: 52px;
}
.card-actions {
margin-top: 20px;
}
.card-actions-top {
margin-bottom: 20px;
}
.card-graph {
display: flex;
align-items: center;
justify-content: center;
}
.card-body--status {
padding: 2.5rem 1.5rem;
text-align: center;
}
.card-body--loading {
position: relative;
}
.card-body--loading:before {
content: '';
position: absolute;
top: 0;
left: 0;
z-index: 100;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.6);
}
.card-body--loading:after {
content: '';
position: absolute;
z-index: 101;
left: 50%;
top: 50%;
width: 40px;
height: 40px;
margin-top: -20px;
margin-left: -20px;
background-image: url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2047.6%2047.6%22%20height%3D%22100%25%22%20width%3D%22100%25%22%3E%3Cpath%20opacity%3D%22.235%22%20fill%3D%22%23979797%22%20d%3D%22M44.4%2011.9l-5.2%203c1.5%202.6%202.4%205.6%202.4%208.9%200%209.8-8%2017.8-17.8%2017.8-6.6%200-12.3-3.6-15.4-8.9l-5.2%203C7.3%2042.8%2015%2047.6%2023.8%2047.6c13.1%200%2023.8-10.7%2023.8-23.8%200-4.3-1.2-8.4-3.2-11.9z%22%2F%3E%3Cpath%20fill%3D%22%2366b574%22%20d%3D%22M3.2%2035.7C0%2030.2-.8%2023.8.8%2017.6%202.5%2011.5%206.4%206.4%2011.9%203.2%2017.4%200%2023.8-.8%2030%20.8c6.1%201.6%2011.3%205.6%2014.4%2011.1l-5.2%203c-2.4-4.1-6.2-7.1-10.8-8.3C23.8%205.4%2019%206%2014.9%208.4s-7.1%206.2-8.3%2010.8c-1.2%204.6-.6%209.4%201.8%2013.5l-5.2%203z%22%2F%3E%3C%2Fsvg%3E');
will-change: transform;
animation: clockwise 2s linear infinite;
}
.card-title-stats {
font-size: 13px;
color: #9aa0ac;
}
.card-body-stats {
position: relative;
flex: 1 1 auto;
margin: 0;
padding: 1rem 1.5rem;
}
.card-value-stats {
display: block;
font-size: 2.1rem;
line-height: 2.7rem;
height: 2.7rem;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.card-value-percent {
position: absolute;
top: 15px;
right: 15px;
font-size: 0.9rem;
line-height: 1;
height: auto;
}
.card-value-percent:after {
content: '%';
}
.card--full {
height: calc(100% - 1.5rem);
}
.card-wrap {
display: flex;
flex-direction: column;
height: 100%;
}
@media screen and (min-width: 1280px) {
.card-title-stats {
font-size: 14px;
}
}
@media (min-width: 992px) {
.dashboard .card:not(.card--full) {
height: 360px;
}
}
.card .logs__cell--red {
background-color: #fff4f2;
}
.card .logs__cell--green {
background-color: #f1faf3;
}
.card .logs__row--blue {
background-color: #ecf7ff;
}
[data-theme='dark'] .card .logs__row--blue {
background-color: var(--logs__row--blue-bgcolor);
}

View File

@@ -0,0 +1,33 @@
import React from 'react';
import './Card.css';
interface CardProps {
id?: string;
title?: string;
subtitle?: string;
bodyType?: string;
type?: string;
refresh?: React.ReactNode;
children: React.ReactNode;
}
const Card = ({ type, id, title, subtitle, refresh, bodyType, children }: CardProps) => (
<div className={type ? `card ${type}` : 'card'} id={id || ''}>
{(title || subtitle) && (
<div className="card-header with-border">
<div className="card-inner">
{title && <div className="card-title">{title}</div>}
{subtitle && <div className="card-subtitle" dangerouslySetInnerHTML={{ __html: subtitle }} />}
</div>
{refresh && <div className="card-options">{refresh}</div>}
</div>
)}
<div className={bodyType || 'card-body'}>{children}</div>
</div>
);
export default Card;

View File

@@ -0,0 +1,113 @@
.checkbox {
display: inline-block;
margin: 0;
}
.checkbox--single {
display: block;
margin: 2px auto 6px;
}
.checkbox--single .checkbox__label:before {
margin-right: 0;
}
.checkbox--settings .checkbox__label:before {
top: 2px;
margin-right: 20px;
}
.checkbox--settings .checkbox__label-title {
margin-bottom: 2px;
font-weight: 600;
}
.checkbox--form .checkbox__label:before {
top: 1px;
margin-right: 10px;
}
.checkbox__label {
position: relative;
display: flex;
align-items: flex-start;
justify-content: center;
font-size: 14px;
font-weight: 400;
user-select: none;
cursor: pointer;
}
.checkbox__label:before {
content: '';
position: relative;
top: 1px;
display: inline-block;
vertical-align: middle;
width: 20px;
height: 20px;
min-width: 20px;
margin-right: 10px;
background-color: var(--checkbox-bg);
background-repeat: no-repeat;
background-position: center center;
background-size: 12px 10px;
border-radius: 3px;
transition:
0.3s ease-in-out box-shadow,
0.3s ease-in-out opacity;
}
.checkbox__label .checkbox__label-text {
line-height: 1.3;
}
.checkbox__label .checkbox__label-text .md__paragraph {
display: inline-block;
vertical-align: baseline;
margin: 0;
text-align: left;
line-height: 1.3;
}
.checkbox__input {
position: absolute;
opacity: 0;
}
.checkbox__input:checked + .checkbox__label:before {
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMi4zIDkuMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0icm91bmQiPjxwYXRoIGQ9Ik0xMS44IDAuNUw1LjMgOC41IDAuNSA0LjIiLz48L3N2Zz4=);
}
.checkbox__input:focus + .checkbox__label:before {
box-shadow: 0 0 1px 1px rgba(74, 74, 74, 0.32);
}
.checkbox__input:disabled + .checkbox__label {
cursor: default;
color: var(--gray);
}
.checkbox__input:disabled + .checkbox__label:before {
opacity: 0.6;
}
.checkbox__label-text {
max-width: 515px;
line-height: 1.5;
}
.checkbox__label-text--long {
max-width: initial;
}
.checkbox__label-title {
display: block;
line-height: 1.5;
}
.checkbox__label-subtitle {
display: block;
line-height: 1.5;
color: var(--scolor);
}

View File

@@ -0,0 +1,50 @@
import React, { forwardRef, ReactNode } from 'react';
import clsx from 'clsx';
import './checkbox.css';
type Props = {
title: string;
subtitle?: ReactNode;
value: boolean;
name?: string;
disabled?: boolean;
className?: string;
error?: string;
onChange: (value: boolean) => void;
onBlur?: () => void;
};
export const Checkbox = forwardRef<HTMLInputElement, Props>(
(
{ title, subtitle, value, name, disabled, error, className = 'checkbox--form', onChange, onBlur, ...rest },
ref,
) => (
<>
<label className={clsx('checkbox', className)}>
<span className="checkbox__marker" />
<input
name={name}
type="checkbox"
className="checkbox__input"
disabled={disabled}
checked={value}
onChange={(e) => onChange(e.target.checked)}
onBlur={onBlur}
ref={ref}
{...rest}
/>
<span className="checkbox__label">
<span className="checkbox__label-text checkbox__label-text--long">
<span className="checkbox__label-title">{title}</span>
{subtitle && <span className="checkbox__label-subtitle">{subtitle}</span>}
</span>
</span>
</label>
{error && <div className="form__message form__message--error">{error}</div>}
</>
),
);
Checkbox.displayName = 'Checkbox';

View File

@@ -0,0 +1,45 @@
import React, { ComponentProps, forwardRef } from 'react';
import clsx from 'clsx';
import { trimLinesAndRemoveEmpty } from '../../../helpers/helpers';
type Props = ComponentProps<'textarea'> & {
className?: string;
wrapperClassName?: string;
label?: string;
desc?: string;
error?: string;
trimOnBlur?: boolean;
};
export const Textarea = forwardRef<HTMLTextAreaElement, Props>(
({ name, label, desc, className, wrapperClassName, error, trimOnBlur, onBlur, ...rest }, ref) => (
<div className={clsx('form-group', wrapperClassName, { 'has-error': !!error })}>
{label && (
<label className={clsx('form__label', { 'form__label--with-desc': !!desc })} htmlFor={name}>
{label}
</label>
)}
{desc && <div className="form__desc form__desc--top">{desc}</div>}
<textarea
className={clsx(
'form-control form-control--textarea form-control--textarea-small font-monospace',
className,
)}
ref={ref}
onBlur={(e) => {
if (trimOnBlur) {
const normalizedValue = trimLinesAndRemoveEmpty(e.target.value);
rest.onChange(normalizedValue);
}
if (onBlur) {
onBlur(e);
}
}}
{...rest}
/>
{error && <div className="form__message form__message--error">{error}</div>}
</div>
),
);
Textarea.displayName = 'Textarea';

View File

@@ -0,0 +1,52 @@
.loading {
position: relative;
z-index: 101;
opacity: 0;
animation: opacity 0.2s linear 0.2s forwards;
}
.loading:before {
content: '';
position: fixed;
top: 0;
left: 0;
z-index: 100;
width: 100%;
min-height: 100vh;
background-color: var(--loading-bg);
}
.loading:after {
content: '';
position: fixed;
z-index: 101;
left: 50%;
top: 50%;
width: 40px;
height: 40px;
margin-top: -20px;
margin-left: -20px;
background-image: url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2047.6%2047.6%22%20height%3D%22100%25%22%20width%3D%22100%25%22%3E%3Cpath%20opacity%3D%22.235%22%20fill%3D%22%23979797%22%20d%3D%22M44.4%2011.9l-5.2%203c1.5%202.6%202.4%205.6%202.4%208.9%200%209.8-8%2017.8-17.8%2017.8-6.6%200-12.3-3.6-15.4-8.9l-5.2%203C7.3%2042.8%2015%2047.6%2023.8%2047.6c13.1%200%2023.8-10.7%2023.8-23.8%200-4.3-1.2-8.4-3.2-11.9z%22%2F%3E%3Cpath%20fill%3D%22%2366b574%22%20d%3D%22M3.2%2035.7C0%2030.2-.8%2023.8.8%2017.6%202.5%2011.5%206.4%206.4%2011.9%203.2%2017.4%200%2023.8-.8%2030%20.8c6.1%201.6%2011.3%205.6%2014.4%2011.1l-5.2%203c-2.4-4.1-6.2-7.1-10.8-8.3C23.8%205.4%2019%206%2014.9%208.4s-7.1%206.2-8.3%2010.8c-1.2%204.6-.6%209.4%201.8%2013.5l-5.2%203z%22%2F%3E%3C%2Fsvg%3E');
will-change: transform;
animation: clockwise 2s linear infinite;
}
@keyframes opacity {
0% {
opacity: 0;
}
100% {
opacity: 0.8;
}
}
@keyframes clockwise {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,17 @@
import React from 'react';
import classNames from 'clsx';
import { useTranslation } from 'react-i18next';
import './Loading.css';
interface LoadingProps {
className?: string;
text?: string;
}
const Loading = ({ className, text }: LoadingProps) => {
const { t } = useTranslation();
return <div className={classNames('loading', className)}>{t(text)}</div>;
};
export default Loading;

View File

@@ -0,0 +1,38 @@
.page-header {
flex-direction: column;
align-items: flex-start;
}
.page-header--logs {
flex-direction: row;
align-items: flex-end;
margin: 0.5rem 0 2.8rem;
}
.page-header--logs .page-title {
display: inline-flex;
align-items: center;
}
@media (max-width: 991px) {
.page-header--logs {
flex-direction: column;
align-items: center;
margin-bottom: 0 0 1.1rem;
}
.page-header--logs .page-title {
margin-bottom: 1.1rem;
font-size: 1.8rem;
}
}
.page-subtitle {
margin-left: 0;
font-size: 0.9rem;
}
.page-title--large {
font-size: 36px;
line-height: 46px;
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import './PageTitle.css';
interface PageTitleProps {
title: string;
subtitle?: string;
children?: React.ReactNode;
containerClass?: string;
}
const PageTitle = ({ title, subtitle, children, containerClass }: PageTitleProps) => (
<div className="page-header">
<div className={containerClass}>
<h1 className="page-title pr-2">{title}</h1>
{children}
</div>
{subtitle && <div className="page-subtitle">{subtitle}</div>}
</div>
);
export default PageTitle;

View File

@@ -0,0 +1,38 @@
import { connect } from 'react-redux';
import { initSettings, toggleSetting } from '../actions';
import { getBlockedServices, updateBlockedServices } from '../actions/services';
import { getStatsConfig, setStatsConfig, resetStats } from '../actions/stats';
import { clearLogs, getLogsConfig, setLogsConfig } from '../actions/queryLogs';
import { getFilteringStatus, setFiltersConfig } from '../actions/filtering';
import { Settings } from '../components/Settings';
const mapStateToProps = (state: any) => {
const { settings, services, stats, queryLogs, filtering } = state;
const props = {
settings,
services,
stats,
queryLogs,
filtering,
};
return props;
};
const mapDispatchToProps = {
initSettings,
toggleSetting,
getBlockedServices,
updateBlockedServices,
getStatsConfig,
setStatsConfig,
resetStats,
clearLogs,
getLogsConfig,
setLogsConfig,
getFilteringStatus,
setFiltersConfig,
};
export default connect(mapStateToProps, mapDispatchToProps)(Settings);

View File

@@ -476,7 +476,7 @@ export const TOAST_TYPES = {
export const SUCCESS_TOAST_TIMEOUT = 5000;
export const ONE_SECOND_IN_MS = 1000;
export const FAILURE_TOAST_TIMEOUT = 30000;
export const FAILURE_TOAST_TIMEOUT = 5000;
export const TOAST_TIMEOUTS = {
[TOAST_TYPES.SUCCESS]: SUCCESS_TOAST_TIMEOUT,

View File

@@ -213,6 +213,7 @@ export type StatsData = {
enabled: boolean;
topUpstreamsAvgTime: { name: string; count: number }[];
topUpstreamsResponses: { name: string; count: number }[];
ignored: string[];
};
export type ClientsData = {
@@ -365,6 +366,7 @@ export type QueryLogsData = {
isDetailed: boolean;
isEntireLog: boolean;
customInterval: any;
ignored: string[];
};
export type ServicesData = {
@@ -566,6 +568,7 @@ export const initialState: RootState = {
isDetailed: true,
isEntireLog: false,
customInterval: null,
ignored: [],
},
rewrites: {
processing: true,
@@ -612,6 +615,7 @@ export const initialState: RootState = {
enabled: true,
topUpstreamsAvgTime: [],
topUpstreamsResponses: [],
ignored: [],
},
toasts: { notices: [] },
};

View File

@@ -0,0 +1,20 @@
.buttonGroup {
display: flex;
align-items: center;
flex-direction: column;
gap: 16px;
padding: 8px 16px;
@media (min-width: 768px) {
flex-direction: row;
}
.button {
flex: 1;
min-width: 200px;
}
}
.input {
padding: 8px 0;
}

View File

@@ -1,37 +1,29 @@
.container {
margin: 0 16px;
padding: 24px 0;
padding: 40px 0;
height: 100%;
min-height: 100vh;
color: var(--default-main-text);
@media (min-width: 768px) {
padding: 48px 24px 80px;
&[data-height='true'] {
min-height: 70vh;
}
}
@media (min-width: 1200px) {
max-width: 1164px;
margin: 0 auto;
&[data-height='true'] {
min-height: 100vh;
}
}
@media (min-width: 1400px) {
min-width: 0;
width: 100%;
}
&_update {
@media (min-width: 1024px) {
margin: 0;
padding: 48px 24px 72px;
max-width: 632px;
}
@media (min-width: 1400px) {
margin: 0 auto;
}
&_full {
max-width: 100%;
}
}
.title {
padding: 0 16px;
}
.subtitle {
padding: 40px 16px 16px 16px;
@media (min-width: 1024px) {
padding-top: 32px;
}
}

View File

@@ -36,7 +36,7 @@
}
}
&.no_decoration {
&.noDecoration {
text-decoration: none;
}
}

View File

@@ -6,6 +6,7 @@ import title from './Title.module.pcss';
import text from './Text.module.pcss';
import dropdown from './Dropdown.module.pcss';
import select from './Select.module.pcss';
import form from './Form.module.pcss';
const theme = {
link,
@@ -16,6 +17,7 @@ const theme = {
text,
dropdown,
select,
form,
};
export default theme;