Pull request #2447: ADG-10293 add general settings page

Merge in DNS/adguard-home from ADG-10293 to ADG-10291

Squashed commit of the following:

commit d488e3d3ce9c1b154dae63af0514e776430cb355
Author: Ildar Kamalov <ik@adguard.com>
Date:   Wed Aug 13 19:40:42 2025 +0300

    fix language change

commit 2c22687bae448c8de3910b90376656a5e68b3a56
Author: Ildar Kamalov <ik@adguard.com>
Date:   Wed Aug 13 19:38:09 2025 +0300

    fix mobile links

commit 372eddc1e7fe3688b59a5c8285c65ef08a852374
Author: Ildar Kamalov <ik@adguard.com>
Date:   Wed Aug 13 11:53:58 2025 +0300

    fix lock

commit 3f6d759b7f
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Aug 12 16:40:31 2025 +0300

    fix placeholder

commit 507631ab71
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Aug 12 16:15:57 2025 +0300

    use batch

commit e495a5585c
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Aug 12 16:13:13 2025 +0300

    review fix

commit 2ba671b408
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Aug 12 10:45:00 2025 +0300

    fix

commit 6f696238a0
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 18:38:28 2025 +0300

    fix language change

commit 8206f3f885
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 14:54:58 2025 +0300

    fix custom radio

commit 0920a51c4a
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 14:30:26 2025 +0300

    fix

commit c7ca1e8eaf
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 14:25:16 2025 +0300

    fix ids

commit 7ecd6426d9
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 13:56:29 2025 +0300

    fix constant import

commit 246f1277b8
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 13:46:11 2025 +0300

    fix version

commit 0224f567e6
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 13:34:29 2025 +0300

    fix version

commit 75c9caffbd
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 13:14:01 2025 +0300

    lint fix

commit d0cf6ad5ab
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 12:40:49 2025 +0300

    lint fix

commit 87eb68e519
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 12:06:35 2025 +0300

    use npm

commit 7df7d60ec9
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 11:23:00 2025 +0300

    rm unused

commit c285ea978a
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 10:42:39 2025 +0300

    fix

commit d00f1e12b3
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Aug 11 09:43:35 2025 +0300

    fix

... and 2 more commits
This commit is contained in:
Ildar Kamalov
2025-08-14 12:09:54 +03:00
parent ba09a14c05
commit 853e12aabf
94 changed files with 19571 additions and 11986 deletions

View File

@@ -21,7 +21,7 @@ GO.MACRO = $${GO:-go}
VERBOSE.MACRO = $${VERBOSE:-0}
CHANNEL = development
CLIENT_DIR = client
CLIENT_DIR = client_v2
DEPLOY_SCRIPT_PATH = not/a/real/path
DIST_DIR = dist
GOAMD64 = v1

75
client_v2/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,75 @@
const path = require('path');
module.exports = {
plugins: ['prettier'],
extends: [
'airbnb-base',
'prettier',
'eslint:recommended',
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended',
'plugin:storybook/recommended',
],
parser: '@typescript-eslint/parser',
env: {
jest: true,
node: true,
browser: true,
commonjs: true,
},
settings: {
react: {
pragma: 'React',
version: '16.4',
},
'import/resolver': {
typescript: {
alwaysTryTypes: true,
project: [path.resolve(__dirname, 'tsconfig.json')],
},
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
},
],
'import/extensions': 'off',
'class-methods-use-this': 'off',
'no-shadow': 'off',
camelcase: 'off',
'no-console': [
'warn',
{
allow: ['warn', 'error'],
},
],
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: true,
},
],
'import/prefer-default-export': 'off',
'no-alert': 'off',
'arrow-body-style': 'off',
'max-len': [
'error',
120,
2,
{
ignoreUrls: true,
ignoreComments: false,
ignoreRegExpLiterals: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
},
],
},
};

View File

@@ -1,88 +0,0 @@
{
"plugins": [
"prettier"
],
"extends": [
"airbnb-base",
"prettier",
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:storybook/recommended"
],
"parser": "@typescript-eslint/parser",
"env": {
"jest": true,
"node": true,
"browser": true,
"commonjs": true
},
"settings": {
"react": {
"pragma": "React",
"version": "16.4"
},
"import/resolver": {
"node": {
"extensions": [
".js",
".jsx",
".ts",
".tsx"
]
}
}
},
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_"
}
],
"import/extensions": [
"error",
"ignorePackages",
{
"js": "never",
"jsx": "never",
"ts": "never",
"tsx": "never"
}
],
"class-methods-use-this": "off",
"no-shadow": "off",
"camelcase": "off",
"no-console": [
"warn",
{
"allow": [
"warn",
"error"
]
}
],
"import/no-extraneous-dependencies": [
"error",
{
"devDependencies": true
}
],
"import/prefer-default-export": "off",
"no-alert": "off",
"arrow-body-style": "off",
"max-len": [
"error",
120,
2,
{
"ignoreUrls": true,
"ignoreComments": false,
"ignoreRegExpLiterals": true,
"ignoreStrings": true,
"ignoreTemplateLiterals": true
}
]
}
}

17186
client_v2/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "dashboard",
"version": "1.0.0",
"version": "2.0.0",
"private": true,
"scripts": {
"build-dev": "cross-env NODE_ENV=development BUILD_ENV=dev webpack --config webpack.dev.js",
@@ -67,8 +67,8 @@
"@storybook/react-webpack5": "^9.0.18",
"@types/lodash": "^4.17.4",
"@types/node": "^22.13.10",
"@types/react": "^17.0.80",
"@types/react-dom": "^18.3.0",
"@types/react": "^16.14.0",
"@types/react-dom": "^16.9.0",
"@types/react-redux": "^7.1.33",
"@types/react-router-dom": "^5.3.3",
"@types/react-table": "^7.7.20",
@@ -84,6 +84,7 @@
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.5.5",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react": "^7.34.1",
@@ -92,6 +93,7 @@
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.6.0",
"jscodeshift": "^0.15.2",
"jsdom": "^26.1.0",
"mini-css-extract-plugin": "^2.9.0",
"path": "^0.12.7",
"postcss": "^8.5.6",

10794
client_v2/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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

@@ -1,3 +1,3 @@
{
"client_settings": "Настройки клиентов"
"privacy_policy": "Политика конфиденциальности"
}

View File

@@ -0,0 +1,470 @@
import { describe, expect, test, afterEach, vi, beforeEach, it } from 'vitest';
import { sortIp, countClientsStatistics, findAddressType, subnetMaskToBitMask } from '../helpers/helpers';
import { ADDRESS_TYPES } from '../helpers/constants';
describe('sortIp', () => {
describe('ipv4', () => {
test('one octet differ', () => {
const arr = ['127.0.2.0', '127.0.3.0', '127.0.1.0'];
const sortedArr = ['127.0.1.0', '127.0.2.0', '127.0.3.0'];
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
});
test('few octets differ', () => {
const arr = [
'192.168.11.10',
'192.168.10.0',
'192.168.11.11',
'192.168.10.10',
'192.168.1.10',
'192.168.0.1',
'192.168.1.0',
'192.168.1.1',
'192.168.11.0',
'192.168.0.10',
'192.168.10.11',
'192.168.0.11',
'192.168.1.11',
'192.168.0.0',
'192.168.10.1',
'192.168.11.1',
];
const sortedArr = [
'192.168.0.0',
'192.168.0.1',
'192.168.0.10',
'192.168.0.11',
'192.168.1.0',
'192.168.1.1',
'192.168.1.10',
'192.168.1.11',
'192.168.10.0',
'192.168.10.1',
'192.168.10.10',
'192.168.10.11',
'192.168.11.0',
'192.168.11.1',
'192.168.11.10',
'192.168.11.11',
];
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
// Example from issue https://github.com/AdguardTeam/AdGuardHome/issues/1778#issuecomment-640937599
const arr2 = [
'192.168.2.11',
'192.168.3.1',
'192.168.2.100',
'192.168.2.2',
'192.168.2.1',
'192.168.2.10',
'192.168.2.99',
'192.168.2.200',
'192.168.2.199',
];
const sortedArr2 = [
'192.168.2.1',
'192.168.2.2',
'192.168.2.10',
'192.168.2.11',
'192.168.2.99',
'192.168.2.100',
'192.168.2.199',
'192.168.2.200',
'192.168.3.1',
];
expect(arr2.sort(sortIp)).toStrictEqual(sortedArr2);
});
});
describe('ipv6', () => {
test('only long form', () => {
const arr = ['2001:db8:11a3:9d7:0:0:0:2', '2001:db8:11a3:9d7:0:0:0:3', '2001:db8:11a3:9d7:0:0:0:1'];
const sortedArr = ['2001:db8:11a3:9d7:0:0:0:1', '2001:db8:11a3:9d7:0:0:0:2', '2001:db8:11a3:9d7:0:0:0:3'];
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
});
test('only short form', () => {
const arr = ['2001:db8::', '2001:db7::', '2001:db9::'];
const sortedArr = ['2001:db7::', '2001:db8::', '2001:db9::'];
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
});
test('long and short forms', () => {
const arr = [
'2001:db8::',
'2001:db7:11a3:9d7:0:0:0:2',
'2001:db6:11a3:9d7:0:0:0:1',
'2001:db6::',
'2001:db7:11a3:9d7:0:0:0:1',
'2001:db7::',
];
const sortedArr = [
'2001:db6::',
'2001:db6:11a3:9d7:0:0:0:1',
'2001:db7::',
'2001:db7:11a3:9d7:0:0:0:1',
'2001:db7:11a3:9d7:0:0:0:2',
'2001:db8::',
];
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
});
});
describe('ipv4 and ipv6', () => {
test('ipv6 long form', () => {
const arr = [
'127.0.0.3',
'2001:db8:11a3:9d7:0:0:0:1',
'2001:db8:11a3:9d7:0:0:0:3',
'127.0.0.1',
'2001:db8:11a3:9d7:0:0:0:2',
'127.0.0.2',
];
const sortedArr = [
'127.0.0.1',
'127.0.0.2',
'127.0.0.3',
'2001:db8:11a3:9d7:0:0:0:1',
'2001:db8:11a3:9d7:0:0:0:2',
'2001:db8:11a3:9d7:0:0:0:3',
];
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
});
test('ipv6 short form', () => {
const arr = [
'2001:db8:11a3:9d7::1',
'127.0.0.3',
'2001:db8:11a3:9d7::3',
'127.0.0.1',
'2001:db8:11a3:9d7::2',
'127.0.0.2',
];
const sortedArr = [
'127.0.0.1',
'127.0.0.2',
'127.0.0.3',
'2001:db8:11a3:9d7::1',
'2001:db8:11a3:9d7::2',
'2001:db8:11a3:9d7::3',
];
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
});
test('ipv6 long and short forms', () => {
const arr = [
'2001:db8:11a3:9d7::1',
'127.0.0.3',
'2001:db8:11a3:9d7:0:0:0:2',
'127.0.0.1',
'2001:db8:11a3:9d7::3',
'127.0.0.2',
];
const sortedArr = [
'127.0.0.1',
'127.0.0.2',
'127.0.0.3',
'2001:db8:11a3:9d7::1',
'2001:db8:11a3:9d7:0:0:0:2',
'2001:db8:11a3:9d7::3',
];
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
});
test('always put ipv4 before ipv6', () => {
const arr = [
'::1',
'0.0.0.2',
'127.0.0.1',
'::2',
'2001:db8:11a3:9d7:0:0:0:2',
'0.0.0.1',
'2001:db8:11a3:9d7::1',
];
const sortedArr = [
'0.0.0.1',
'0.0.0.2',
'127.0.0.1',
'::1',
'::2',
'2001:db8:11a3:9d7::1',
'2001:db8:11a3:9d7:0:0:0:2',
];
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
});
});
describe('cidr', () => {
test('only ipv4 cidr', () => {
const arr = ['192.168.0.1/9', '192.168.0.1/7', '192.168.0.1/8'];
const sortedArr = ['192.168.0.1/7', '192.168.0.1/8', '192.168.0.1/9'];
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
});
test('ipv4 and cidr ipv4', () => {
const arr = ['192.168.0.1/9', '192.168.0.1', '192.168.0.1/32', '192.168.0.1/7', '192.168.0.1/8'];
const sortedArr = ['192.168.0.1/7', '192.168.0.1/8', '192.168.0.1/9', '192.168.0.1/32', '192.168.0.1'];
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
});
test('only ipv6 cidr', () => {
const arr = [
'2001:db8:11a3:9d7::1/32',
'2001:db8:11a3:9d7::1/64',
'2001:db8:11a3:9d7::1/128',
'2001:db8:11a3:9d7::1/24',
];
const sortedArr = [
'2001:db8:11a3:9d7::1/24',
'2001:db8:11a3:9d7::1/32',
'2001:db8:11a3:9d7::1/64',
'2001:db8:11a3:9d7::1/128',
];
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
});
test('ipv6 and cidr ipv6', () => {
const arr = [
'2001:db8:11a3:9d7::1/32',
'2001:db8:11a3:9d7::1',
'2001:db8:11a3:9d7::1/64',
'2001:db8:11a3:9d7::1/128',
'2001:db8:11a3:9d7::1/24',
];
const sortedArr = [
'2001:db8:11a3:9d7::1/24',
'2001:db8:11a3:9d7::1/32',
'2001:db8:11a3:9d7::1/64',
'2001:db8:11a3:9d7::1/128',
'2001:db8:11a3:9d7::1',
];
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
});
});
describe('invalid input', () => {
const originalWarn = console.warn;
beforeEach(() => {
console.warn = vi.fn();
});
afterEach(() => {
expect(console.warn).toHaveBeenCalled();
console.warn = originalWarn;
});
test('invalid strings', () => {
const arr = ['invalid ip', 'invalid cidr'];
expect(arr.sort(sortIp)).toStrictEqual(arr);
});
test('invalid ip', () => {
const arr = ['127.0.0.2.', '.127.0.0.1.', '.2001:db8:11a3:9d7:0:0:0:0'];
expect(arr.sort(sortIp)).toStrictEqual(arr);
});
test('invalid cidr', () => {
const arr = ['127.0.0.2/33', '2001:db8:11a3:9d7:0:0:0:0/129'];
expect(arr.sort(sortIp)).toStrictEqual(arr);
});
test('valid and invalid ip', () => {
const arr = ['127.0.0.4.', '127.0.0.1', '.127.0.0.3', '127.0.0.2'];
expect(arr.sort(sortIp)).toStrictEqual(arr);
});
});
describe('mixed', () => {
test('ipv4, ipv6 in short and long forms and cidr', () => {
const arr = [
'2001:db8:11a3:9d7:0:0:0:1/32',
'192.168.1.2',
'127.0.0.2',
'2001:db8:11a3:9d7::1/128',
'2001:db8:11a3:9d7:0:0:0:1',
'127.0.0.1/12',
'192.168.1.1',
'2001:db8::/32',
'2001:db8:11a3:9d7::1/24',
'192.168.1.2/12',
'2001:db7::/32',
'127.0.0.1',
'2001:db8:11a3:9d7:0:0:0:2',
'192.168.1.1/24',
'2001:db7::/64',
'2001:db7::',
'2001:db8::',
'2001:db8:11a3:9d7:0:0:0:1/128',
'192.168.1.1/12',
'127.0.0.1/32',
'::1',
];
const sortedArr = [
'127.0.0.1/12',
'127.0.0.1/32',
'127.0.0.1',
'127.0.0.2',
'192.168.1.1/12',
'192.168.1.1/24',
'192.168.1.1',
'192.168.1.2/12',
'192.168.1.2',
'::1',
'2001:db7::/32',
'2001:db7::/64',
'2001:db7::',
'2001:db8::/32',
'2001:db8::',
'2001:db8:11a3:9d7::1/24',
'2001:db8:11a3:9d7:0:0:0:1/32',
'2001:db8:11a3:9d7::1/128',
'2001:db8:11a3:9d7:0:0:0:1/128',
'2001:db8:11a3:9d7:0:0:0:1',
'2001:db8:11a3:9d7:0:0:0:2',
];
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
});
});
});
describe('findAddressType', () => {
it('should return IP type for IP addresses', () => {
expect(findAddressType('127.0.0.1')).toStrictEqual(ADDRESS_TYPES.IP);
});
it('should return CIDR type for CIDR addresses', () => {
expect(findAddressType('127.0.0.1/8')).toStrictEqual(ADDRESS_TYPES.CIDR);
});
it('should return UNKNOWN type for MAC addresses', () => {
expect(findAddressType('00:1B:44:11:3A:B7')).toStrictEqual(ADDRESS_TYPES.UNKNOWN);
});
});
describe('countClientsStatistics', () => {
test('single ip', () => {
expect(
countClientsStatistics(['127.0.0.1'], {
'127.0.0.1': 1,
}),
).toStrictEqual(1);
});
test('multiple ip', () => {
expect(
countClientsStatistics(['127.0.0.1', '127.0.0.2'], {
'127.0.0.1': 1,
'127.0.0.2': 2,
}),
).toStrictEqual(1 + 2);
});
test('cidr', () => {
expect(
countClientsStatistics(['127.0.0.0/8'], {
'127.0.0.1': 1,
'127.0.0.2': 2,
}),
).toStrictEqual(1 + 2);
});
test('cidr and multiple ip', () => {
expect(
countClientsStatistics(['1.1.1.1', '2.2.2.2', '3.3.3.0/24'], {
'1.1.1.1': 1,
'2.2.2.2': 2,
'3.3.3.3': 3,
}),
).toStrictEqual(1 + 2 + 3);
});
test('mac', () => {
expect(
countClientsStatistics(['00:1B:44:11:3A:B7', '2.2.2.2', '3.3.3.0/24'], {
'1.1.1.1': 1,
'2.2.2.2': 2,
'3.3.3.3': 3,
}),
).toStrictEqual(2 + 3);
});
test('not found', () => {
expect(
countClientsStatistics(['4.4.4.4', '5.5.5.5', '6.6.6.6'], {
'1.1.1.1': 1,
'2.2.2.2': 2,
'3.3.3.3': 3,
}),
).toStrictEqual(0);
});
});
describe('subnetMaskToBitMask', () => {
const subnetMasks = [
'0.0.0.0',
'128.0.0.0',
'192.0.0.0',
'224.0.0.0',
'240.0.0.0',
'248.0.0.0',
'252.0.0.0',
'254.0.0.0',
'255.0.0.0',
'255.128.0.0',
'255.192.0.0',
'255.224.0.0',
'255.240.0.0',
'255.248.0.0',
'255.252.0.0',
'255.254.0.0',
'255.255.0.0',
'255.255.128.0',
'255.255.192.0',
'255.255.224.0',
'255.255.240.0',
'255.255.248.0',
'255.255.252.0',
'255.255.254.0',
'255.255.255.0',
'255.255.255.128',
'255.255.255.192',
'255.255.255.224',
'255.255.255.240',
'255.255.255.248',
'255.255.255.252',
'255.255.255.254',
'255.255.255.255',
];
test('correct for all subnetMasks', () => {
expect(
subnetMasks
.map((subnetMask) => {
const bitmask = subnetMaskToBitMask(subnetMask);
return subnetMasks[bitmask] === subnetMask;
})
.every((res) => res === true),
).toEqual(true);
});
});

View File

@@ -1,6 +1,6 @@
import { createAction } from 'redux-actions';
import i18next from 'i18next';
import intl from 'panel/common/intl';
import { apiClient } from '../api/Api';
import { splitByNewLine } from '../helpers/helpers';
@@ -30,7 +30,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 +71,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

@@ -1,4 +1,5 @@
import { createAction } from 'redux-actions';
import intl from 'panel/common/intl';
import { apiClient } from '../api/Api';
import { redirectToCurrentProtocol } from '../helpers/helpers';
@@ -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

@@ -6,6 +6,8 @@ import endsWith from 'lodash/endsWith';
import escapeRegExp from 'lodash/escapeRegExp';
import React from 'react';
import { compose } from 'redux';
import type { Dispatch } from 'redux';
import intl, { type LocalesType } from 'panel/common/intl';
import {
splitByNewLine,
sortClients,
@@ -21,6 +23,7 @@ import {
SETTINGS_NAMES,
MANUAL_UPDATE_LINK,
DISABLE_PROTECTION_TIMINGS,
THEMES,
} from '../helpers/constants';
import { areEqualVersions } from '../helpers/version';
import { getTlsStatus } from './encryption';
@@ -28,7 +31,14 @@ import { apiClient } from '../api/Api';
import { addErrorToast, addNoticeToast, addSuccessToast } from './toasts';
import { getFilteringStatus, setRules } from './filtering';
export const toggleSettingStatus = createAction('SETTING_STATUS_TOGGLE');
type SafeSearchConfig = Record<string, boolean> & { enabled: boolean };
type ToggleSettingKey = keyof typeof SETTINGS_NAMES;
type Theme = typeof THEMES[keyof typeof THEMES];
export const toggleSettingStatus = createAction<{
settingKey: ToggleSettingKey;
value?: boolean | SafeSearchConfig;
}>('SETTING_STATUS_TOGGLE');
export const showSettingsFailure = createAction('SETTINGS_FAILURE_SHOW');
/**
@@ -37,81 +47,70 @@ export const showSettingsFailure = createAction('SETTINGS_FAILURE_SHOW');
* @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: { enabled: boolean };
parental: { enabled: boolean };
safesearch: SafeSearchConfig;
};
export const initSettingsSuccess = createAction<{ settingsList: SettingsSuccessList }>('SETTINGS_INIT_SUCCESS');
export const initSettings =
(
settingsList = {
safebrowsing: {},
parental: {},
},
) =>
async (dispatch: any) => {
dispatch(initSettingsRequest());
try {
const safebrowsingStatus = await apiClient.getSafebrowsingStatus();
const parentalStatus = await apiClient.getParentalStatus();
const safesearchStatus = await apiClient.getSafesearchStatus();
const { safebrowsing, parental } = settingsList;
const newSettingsList = {
safebrowsing: {
...safebrowsing,
enabled: safebrowsingStatus.enabled,
},
parental: {
...parental,
enabled: parentalStatus.enabled,
},
safesearch: {
...safesearchStatus,
},
};
dispatch(initSettingsSuccess({ settingsList: newSettingsList }));
} catch (error) {
dispatch(addErrorToast({ error }));
dispatch(initSettingsFailure());
}
};
export const initSettings = () => async (dispatch: Dispatch) => {
dispatch(initSettingsRequest());
try {
const safebrowsingStatus = await apiClient.getSafebrowsingStatus();
const parentalStatus = await apiClient.getParentalStatus();
const safesearchStatus = (await apiClient.getSafesearchStatus()) as SafeSearchConfig;
const newSettingsList: SettingsSuccessList = {
safebrowsing: {
enabled: safebrowsingStatus.enabled,
},
parental: {
enabled: parentalStatus.enabled,
},
safesearch: {
...safesearchStatus,
},
};
dispatch(initSettingsSuccess({ settingsList: newSettingsList }));
} catch (error) {
dispatch(addErrorToast({ error }));
dispatch(initSettingsFailure());
}
};
export const toggleProtectionRequest = createAction('TOGGLE_PROTECTION_REQUEST');
export const toggleProtectionFailure = createAction('TOGGLE_PROTECTION_FAILURE');
@@ -120,19 +119,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 +144,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 +177,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 +414,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());
@@ -442,13 +443,13 @@ export const testUpstreamWithFormValues = (formValues: any) => async (dispatch:
export const changeLanguageRequest = createAction('CHANGE_LANGUAGE_REQUEST');
export const changeLanguageFailure = createAction('CHANGE_LANGUAGE_FAILURE');
export const changeLanguageSuccess = createAction('CHANGE_LANGUAGE_SUCCESS');
export const changeLanguageSuccess = createAction<{ language: LocalesType }>('CHANGE_LANGUAGE_SUCCESS');
export const changeLanguage = (lang: any) => async (dispatch: any) => {
export const changeLanguage = (lang: LocalesType) => async (dispatch: Dispatch) => {
dispatch(changeLanguageRequest());
try {
await apiClient.changeLanguage({ language: lang });
dispatch(changeLanguageSuccess());
dispatch(changeLanguageSuccess({ language: lang }));
} catch (error) {
dispatch(addErrorToast({ error }));
dispatch(changeLanguageFailure());
@@ -457,9 +458,9 @@ export const changeLanguage = (lang: any) => async (dispatch: any) => {
export const changeThemeRequest = createAction('CHANGE_THEME_REQUEST');
export const changeThemeFailure = createAction('CHANGE_THEME_FAILURE');
export const changeThemeSuccess = createAction('CHANGE_THEME_SUCCESS');
export const changeThemeSuccess = createAction<{ theme: Theme }>('CHANGE_THEME_SUCCESS');
export const changeTheme = (theme: any) => async (dispatch: any) => {
export const changeTheme = (theme: Theme) => async (dispatch: Dispatch) => {
dispatch(changeThemeRequest());
try {
await apiClient.changeTheme({ theme });
@@ -572,7 +573,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 +590,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 +607,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 +636,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 +652,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 +671,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 +690,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 +706,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 +735,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

@@ -1,5 +1,6 @@
import { createAction } from 'redux-actions';
import intl from 'panel/common/intl';
import { apiClient } from '../api/Api';
import { normalizeLogs } from '../helpers/helpers';
@@ -173,7 +174,7 @@ export const clearLogs = () => async (dispatch: any) => {
try {
await apiClient.clearQueryLog();
dispatch(clearLogsSuccess());
dispatch(addSuccessToast('query_log_cleared'));
dispatch(addSuccessToast(intl.getMessage('settings_notify_query_log_cleared')));
} catch (error) {
dispatch(addErrorToast({ error }));
dispatch(clearLogsFailure(error));
@@ -203,7 +204,7 @@ export const setLogsConfig = (config: any) => async (dispatch: any) => {
dispatch(setLogsConfigRequest());
try {
await apiClient.setQueryLogConfig(config);
dispatch(addSuccessToast('config_successfully_saved'));
dispatch(addSuccessToast(intl.getMessage('settings_notify_changes_saved')));
dispatch(setLogsConfigSuccess(config));
} catch (error) {
dispatch(addErrorToast({ error }));

View File

@@ -1,5 +1,6 @@
import { createAction } from 'redux-actions';
import intl from 'panel/common/intl';
import { apiClient } from '../api/Api';
import { normalizeTopStats, secondsToMilliseconds, getParamsForClientsSearch, addClientInfo } from '../helpers/helpers';
import { addErrorToast, addSuccessToast } from './toasts';
@@ -27,7 +28,7 @@ export const setStatsConfig = (config: any) => async (dispatch: any) => {
dispatch(setStatsConfigRequest());
try {
await apiClient.setStatsConfig(config);
dispatch(addSuccessToast('config_successfully_saved'));
dispatch(addSuccessToast(intl.getMessage('settings_notify_changes_saved')));
dispatch(setStatsConfigSuccess(config));
} catch (error) {
dispatch(addErrorToast({ error }));
@@ -74,7 +75,7 @@ export const resetStats = () => async (dispatch: any) => {
dispatch(getStatsRequest());
try {
await apiClient.resetStats();
dispatch(addSuccessToast('statistics_cleared'));
dispatch(addSuccessToast(intl.getMessage('settings_notify_statistics_cleared')));
dispatch(resetStatsSuccess());
} catch (error) {
dispatch(addErrorToast({ error }));

View File

@@ -1,5 +1,7 @@
import axios from 'axios';
import type { LocalesType } from 'panel/common/intl';
import { BASE_URL } from '../../constants';
import { getPathWithQueryString } from '../helpers/helpers';
@@ -7,6 +9,8 @@ import { QUERY_LOGS_PAGE_LIMIT, HTML_PAGES, R_PATH_LAST_PART, THEMES } from '../
import i18n from '../i18n';
import { LANGUAGES } from '../helpers/twosky';
type Theme = typeof THEMES[keyof typeof THEMES];
class Api {
baseUrl: string;
@@ -258,8 +262,7 @@ class Api {
// }
// Language
async changeLanguage(config: any) {
async changeLanguage(config: { language: LocalesType }) {
const profile = await this.getProfile();
profile.language = config.language;
@@ -267,8 +270,7 @@ class Api {
}
// Theme
async changeTheme(config: any) {
async changeTheme(config: { theme: Theme }) {
const profile = await this.getProfile();
profile.theme = config.theme;

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

@@ -1,4 +1,4 @@
import React, { ChangeEvent, ComponentProps, MouseEvent, ReactNode } from 'react';
import React, { ComponentProps, MouseEvent, ReactNode } from 'react';
import cn from 'clsx';
import theme from 'panel/lib/theme';
import { Icon } from 'panel/common/ui/Icon';
@@ -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

@@ -1,4 +1,4 @@
import React, { ChangeEvent, ComponentProps, forwardRef, type ReactNode, useState } from 'react';
import React, { ComponentProps, forwardRef, type ReactNode, useState } from 'react';
import cn from 'clsx';
import s from './Input.module.pcss';

View File

@@ -1,8 +1,8 @@
import React from 'react';
import cn from 'clsx';
import s from './Radio.module.pcss';
import { Icon } from 'panel/common/ui/Icon';
import s from './Radio.module.pcss';
type Props<T> = {
className?: string;
@@ -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}-${o.value}` : String(o.value)}
className={cn(s.radio, className)}>
<input
id={String(o.value)}
id={name ? `${name}-${o.value}` : String(o.value)}
type="radio"
className={s.input}
name={name}
onChange={() => handleChange(o.value)}
checked={value === o.value}
disabled={disabled}

View File

@@ -22,8 +22,8 @@ export type ISelectValue<T, Multi extends boolean> = Multi extends true ? IOptio
const CustomMenuList = <
T,
Multi extends boolean,
Group extends GroupBase<IOption<T> & ExtendOption>,
ExtendOption extends Record<string, any>,
Group extends GroupBase<IOption<T> & ExtendOption>,
>(
props: MenuListProps<IOption<T> & ExtendOption, Multi, Group>,
): React.ReactElement => {
@@ -45,8 +45,8 @@ const CustomMenuList = <
interface SelectProps<
T,
Multi extends boolean,
Group extends GroupBase<IOption<T> & ExtendOption>,
ExtendOption extends Record<string, any>,
Group extends GroupBase<IOption<T> & ExtendOption>,
> {
size?: ISelectSize;
height?: ISelectHeight;
@@ -84,7 +84,7 @@ interface SelectProps<
export const Select = <
T,
Multi extends boolean = false,
ExtendOption extends Record<any, any> = {},
ExtendOption extends Record<any, any> = object,
Group extends GroupBase<IOption<T> & ExtendOption> = GroupBase<IOption<T> & ExtendOption>,
>({
autoFocus,
@@ -118,7 +118,7 @@ export const Select = <
isDropdownSelect,
onMenuOpen,
onMenuClose,
}: SelectProps<T, Multi, Group, ExtendOption>) => {
}: SelectProps<T, Multi, ExtendOption, Group>) => {
const selectClass = cn(
{ 'desktop-select-always': mobile === false },
{ 'mobile-select-always': mobile },

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;
Switch.displayName = 'Switch';

View File

@@ -1,45 +1,57 @@
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>
),
);
Textarea.displayName = 'Textarea';

View File

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

View File

@@ -15,6 +15,7 @@ import ptPt from 'panel/__locales/pt-pt.json';
import ru from 'panel/__locales/ru.json';
import zhCn from 'panel/__locales/zh-cn.json';
import zhTw from 'panel/__locales/zh-tw.json';
import { LOCAL_STORAGE_KEYS, LocalStorageHelper } from 'panel/helpers/localStorageHelper';
export type LocalesType = ReturnType<I18nInterface['getUILanguage']>;
@@ -37,14 +38,55 @@ const LOCALES = {
const messages: LocalesTypes = LOCALES;
export const i18n = (lang: LocalesType) => ({
getMessage: (key: string) => messages[lang]?.[key] || '',
getUILanguage: () => (lang.includes('zh') ? 'zh' : lang),
getBaseMessage: (key: string) => messages.en![key] || key,
getBaseUILanguage: () => BASE_LOCALE as LocalesType,
});
const resolveLanguage = (lng: string): LocalesType => {
const l = lng.toLowerCase();
let currentLanguage: LocalesType = BASE_LOCALE as LocalesType;
if (messages[l as LocalesType]) {
return l as LocalesType;
}
// Chinese locales
if (l.startsWith('zh')) {
if (l.includes('tw') || l.includes('hk') || l.includes('mo')) {
return 'zh-tw' as LocalesType;
}
return 'zh-cn' as LocalesType;
}
// Portuguese locales
if (l.startsWith('pt')) {
if (l.includes('br')) {
return 'pt-br' as LocalesType;
}
return 'pt-pt' as LocalesType;
}
// Try base language (e.g., en-us -> en)
const base = l.split('-')[0] as LocalesType;
if (messages[base]) {
return base;
}
return BASE_LOCALE as LocalesType;
};
export const i18n = (lang: LocalesType) => {
const resolved = resolveLanguage(lang);
return {
getMessage: (key: string) => messages[resolved]?.[key] || '',
getUILanguage: () => resolved,
getBaseMessage: (key: string) => messages.en![key] || key,
getBaseUILanguage: () => BASE_LOCALE as LocalesType,
};
};
const detectedLanguage = (
(typeof window !== 'undefined' && typeof localStorage !== 'undefined' && LocalStorageHelper.getItem(LOCAL_STORAGE_KEYS.LANGUAGE)) ||
(typeof navigator !== 'undefined' && (navigator.language as string)) ||
BASE_LOCALE
) as LocalesType;
let currentLanguage: LocalesType = resolveLanguage(detectedLanguage);
let translator = translate.createReactTranslator<any>(i18n(currentLanguage), React, {
tags: [
@@ -64,6 +106,10 @@ const intl = {
return translator.getMessage(key, values);
},
getPlural: (key: string, values?: any) => {
return translator.getPlural(key, values);
},
getUILanguage: () => currentLanguage,
getBaseMessage: (key: string) => {
@@ -73,8 +119,9 @@ const intl = {
getBaseUILanguage: () => BASE_LOCALE as LocalesType,
changeLanguage: (newLang: LocalesType) => {
currentLanguage = newLang;
translator = translate.createReactTranslator<any>(i18n(newLang), React, {
const resolved = resolveLanguage(newLang);
currentLanguage = resolved;
translator = translate.createReactTranslator<any>(i18n(resolved), React, {
tags: [
{
key: 'br',

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

@@ -3,6 +3,7 @@
width: 100%;
@media (min-width: 768px) {
max-width: 100%;
width: 720px;
}
}

View File

@@ -5,7 +5,9 @@ import cn from 'clsx';
import theme from 'panel/lib/theme';
import { Dropdown } from 'panel/common/ui/Dropdown';
import { Icon } from 'panel/common/ui/Icon';
import intl, { LocalesType } from 'panel/common/intl';
import { LOCAL_STORAGE_KEYS, LocalStorageHelper } from 'panel/helpers/localStorageHelper';
import { REPOSITORY, PRIVACY_POLICY_LINK, THEMES } from '../../../helpers/constants';
import { LANGUAGES } from '../../../helpers/twosky';
import { setHtmlLangAttr, setUITheme } from '../../../helpers/helpers';
@@ -13,7 +15,6 @@ import { changeTheme, changeLanguage as changeLanguageAction } from '../../../ac
import { RootState } from '../../../initialState';
import s from './styles.module.pcss';
import intl, { LocalesType } from 'panel/common/intl';
const linksData = [
{
@@ -73,11 +74,11 @@ export const Footer = () => {
};
const changeLanguage = async (newLang: LocalesType) => {
intl.changeLanguage(newLang);
setHtmlLangAttr(newLang);
try {
await dispatch(changeLanguageAction(newLang));
LocalStorageHelper.setItem(LOCAL_STORAGE_KEYS.LANGUAGE, newLang);
window.location.reload();
} catch (error) {
console.error('Failed to save language preference:', error);
@@ -105,7 +106,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}
@@ -156,7 +157,10 @@ export const Footer = () => {
className={cn(theme.dropdown.item, {
[theme.dropdown.item_active]: currentLanguage === lang,
})}
onClick={() => changeLanguage(lang as LocalesType)}>
onClick={() => {
changeLanguage(lang as LocalesType);
setLangDropdownOpen(false);
}}>
{LANGUAGES[lang]}
</button>
))}

View File

@@ -24,6 +24,7 @@
.links {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 16px;
@media (min-width: 1024px) {

View File

@@ -14,14 +14,14 @@
align-items: center;
margin: 0 auto;
height: 100%;
padding: 0 20px;
padding: 0 16px;
@media (min-width: 768px) {
padding: 0 24px;
}
@media (min-width: 1024px) {
padding: 0 var(--m-xl);
padding: 0 40px;
}
}

View File

@@ -25,6 +25,7 @@ export const ICONS = {
loader: 'loader',
check: 'check',
dot: 'dot',
attention: 'attention',
} as const;
export type IconType = keyof typeof ICONS;
@@ -319,6 +320,33 @@ export const Icons = memo(() => (
fill="currentColor"
/>
</symbol>
<symbol id="attention" viewBox="0 0 24 24" fill="none">
<circle
cx="9"
cy="9"
r="9"
transform="matrix(1 0 0 -1 3 21)"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 8V14"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M11.997 16.4045C12.009 16.4025 11.997 16.5955 11.997 16.5955"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</symbol>
</svg>
));
Icons.displayName = 'Icons';

View File

@@ -30,7 +30,7 @@
}
.chartOverlay,
.modalOverlay {
.pageOverlay {
display: flex;
flex-direction: column;
align-items: center;
@@ -39,18 +39,23 @@
padding: 8px 16px;
}
.modalOverlay {
.pageOverlay {
min-height: 440px;
}
.chartLoader,
.modalLoader {
.chartLoader {
width: 72px;
height: 72px;
color: var(--default-link);
color: var(--default-product-icon);
@media (min-width: 768px) {
width: 96px;
height: 96px;
}
}
.pageLoader {
width: 72px;
height: 72px;
color: var(--default-product-icon);
}

View File

@@ -1,6 +0,0 @@
import React from 'react';
import { Loader } from './Loader';
import s from './Loader.module.pcss';
export const ModalLoader = () => <Loader overlayClassName={s.modalOverlay} className={s.modalLoader} icon="loader" />;

View File

@@ -0,0 +1,6 @@
import React from 'react';
import { Loader } from './Loader';
import s from './Loader.module.pcss';
export const PageLoader = () => <Loader overlayClassName={s.pageOverlay} className={s.pageLoader} icon="loader" />;

View File

@@ -1,2 +1,3 @@
export { InlineLoader } from './InlineLoader';
export { Loader } from './Loader';
export { PageLoader } from './PageLoader';

View File

@@ -36,7 +36,7 @@ export const Menu = ({ headerMenu }: Props) => {
return false;
}
const normalizedPath = p.endsWith('/') ? p : p + '/';
const normalizedPath = p.endsWith('/') ? p : `${p}/`;
return currentPath.startsWith(normalizedPath);
});
};
@@ -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,
},
@@ -124,7 +124,12 @@ export const Menu = ({ headerMenu }: Props) => {
</nav>
<div className={s.referenceWrapper}>
<div className={cn(s.menuLinkWrapper)}>
<a href={apiClient.getLogoutUrl()} target="_blank" rel="noopener noreferrer" className={s.menuLink}>
<a
href={apiClient.getLogoutUrl()}
target="_blank"
rel="noopener noreferrer"
className={s.menuLink}
id="sign_out">
<Icon className={s.linkIcon} icon="logout" />
<span className={theme.common.textOverflow}>{intl.getMessage('logout')}</span>
</a>

View File

@@ -7,14 +7,15 @@ import { Sidebar } from 'panel/common/ui/Sidebar';
import { Icons } from 'panel/common/ui/Icons';
import { Footer } from 'panel/common/ui/Footer';
import { Header } from 'panel/common/ui/Header';
import { Settings } from 'panel/components/Settings';
import { LocalesType } from 'panel/common/intl';
import { LOCAL_STORAGE_KEYS, LocalStorageHelper } from 'panel/helpers/localStorageHelper';
import Toasts from '../Toasts';
import i18n from '../../i18n';
import { THEMES } from '../../helpers/constants';
import { setHtmlLangAttr, setUITheme } from '../../helpers/helpers';
import { changeLanguage, getDnsStatus, getTimerStatus } from '../../actions';
import { RootState } from '../../initialState';
import s from './styles.module.pcss';
@@ -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();
@@ -55,10 +62,11 @@ const App = () => {
if (language) {
i18n.changeLanguage(language);
setHtmlLangAttr(language);
LocalStorageHelper.setItem(LOCAL_STORAGE_KEYS.LANGUAGE, language);
}
}
i18n.on('languageChanged', (lang) => {
i18n.on('languageChanged', (lang: LocalesType) => {
dispatch(changeLanguage(lang));
});
};

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,80 @@
import React, { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import intl from 'panel/common/intl';
import theme from 'panel/lib/theme';
import { RoutePath } from 'panel/components/Routes/Paths';
import { Link } from 'panel/common/ui/Link';
import { setFiltersConfig } from 'panel/actions/filtering';
import { useDispatch } from 'react-redux';
import { SwitchGroup } from './SettingsGroup';
export type FormValues = {
enabled: boolean;
interval: number;
};
type Props = {
initialValues: FormValues;
processing: boolean;
};
export const FiltersConfig = ({ initialValues, processing }: Props) => {
const dispatch = useDispatch();
const { watch, control, setValue } = useForm({
mode: 'onBlur',
defaultValues: initialValues,
});
const enabled = watch('enabled');
useEffect(() => {
dispatch(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,87 @@
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 intl from 'panel/common/intl';
import { trimLinesAndRemoveEmpty } from 'panel/helpers/helpers';
import { Dropdown } from 'panel/common/ui/Dropdown';
import theme from 'panel/lib/theme';
import { Icon } from 'panel/common/ui/Icon';
import s from './styles.module.pcss';
import { SwitchGroup } from '../SettingsGroup';
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,28 @@
.link {
margin-left: auto;
}
.label {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.dropdownTrigger {
cursor: pointer;
color: var(--default-gray-icons);
}
.dropdownMenu {
padding: 16px;
max-width: 240px;
@media (min-width: 768px) {
max-width: 280px;
}
}
.dropdownTitle {
margin-bottom: 16px;
}

View File

@@ -0,0 +1,155 @@
import React, { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import intl from 'panel/common/intl';
import { Button } from 'panel/common/ui/Button';
import theme from 'panel/lib/theme';
import { QUERY_LOG_INTERVALS_DAYS, RETENTION_CUSTOM } from 'panel/helpers/constants';
import { RadioGroup } from '../SettingsGroup/RadioGroup';
import { SwitchGroup } from '../SettingsGroup';
import { IgnoredDomains } from '../IgnoredDomains';
import { getIntervalTitle, getDefaultInterval } from '../helpers';
import { RetentionCustomInput } from '../RetentionCustomInput';
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: getDefaultInterval(initialValues.customInterval, initialValues.interval),
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,
})),
]}>
<RetentionCustomInput
control={control}
processing={processing}
intervalValue={intervalValue}
intervals={QUERY_LOG_INTERVALS_DAYS}
inputId="logs_config_custom_interval"
inputLabel={intl.getMessage('settings_log_rotation_hours')}
placeholder={intl.getMessage('settings_rotation_placeholder')}
/>
</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"
id="logs_config_save"
variant="primary"
size="small"
disabled={disableSubmit}
className={theme.form.button}>
{intl.getMessage('save')}
</Button>
<Button
type="button"
id="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 { 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';
import { useDispatch } from 'react-redux';
import { clearLogs, setLogsConfig } from 'panel/actions/queryLogs';
import { Form, FormValues } from './Form';
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;
};
export const LogsConfig = ({
interval: prevInterval,
customInterval,
enabled,
anonymize_client_ip,
processing,
processingClear,
ignored,
}: Props) => {
const dispatch = useDispatch();
const [openConfirmDialog, setOpenConfirmDialog] = useState(false);
const [confirmConfig, setConfirmConfig] = useState<LogsConfigPayload | null>(null);
const handleClear = () => {
setOpenConfirmDialog(true);
};
const handleClose = () => {
setOpenConfirmDialog(false);
};
const handleClearConfirm = () => {
dispatch(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;
}
dispatch(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={() => {
dispatch(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,68 @@
import React, { useEffect, useRef } from 'react';
import { Controller, type Control, type Path } from 'react-hook-form';
import theme from 'panel/lib/theme';
import { Input } from 'panel/common/controls/Input';
import { RETENTION_CUSTOM, RETENTION_RANGE } from 'panel/helpers/constants';
import { toNumber } from 'panel/helpers/form';
type Props<TFormValues extends { customInterval?: number | null }> = {
control: Control<TFormValues>;
processing: boolean;
intervalValue: number;
intervals: number[];
inputId: string;
inputLabel: string;
placeholder: string;
};
export const RetentionCustomInput = <TFormValues extends { customInterval?: number | null }>({
control,
processing,
intervalValue,
intervals,
inputId,
inputLabel,
placeholder,
}: Props<TFormValues>) => {
const inputRef = useRef<HTMLInputElement>(null);
const prevIntervalRef = useRef(intervalValue);
useEffect(() => {
const wasCustom = prevIntervalRef.current === RETENTION_CUSTOM;
const isCustom = intervalValue === RETENTION_CUSTOM;
prevIntervalRef.current = intervalValue;
if (!wasCustom && isCustom && !processing) {
inputRef.current?.focus({ preventScroll: true });
}
}, [intervalValue, processing]);
return (
<Controller
name={'customInterval' as Path<TFormValues>}
control={control}
render={({ field, fieldState }) => (
<div className={theme.form.input}>
<Input
ref={inputRef}
id={inputId}
label={inputLabel}
placeholder={placeholder}
value={field.value ?? ''}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
}}
onBlur={field.onBlur}
disabled={processing || intervals.includes(intervalValue)}
error={!!fieldState.error}
min={RETENTION_RANGE.MIN}
max={RETENTION_RANGE.MAX}
/>
</div>
)}
/>
);
};

View File

@@ -0,0 +1,182 @@
import React, { useEffect } from 'react';
import cn from 'clsx';
import { shallowEqual, useDispatch, useSelector, batch } from 'react-redux';
import intl from 'panel/common/intl';
import { Checkbox } from 'panel/common/controls/Checkbox';
import theme from 'panel/lib/theme';
import { PageLoader } from 'panel/common/ui/Loader';
import { RootState, SettingsData } from 'panel/initialState';
import { initSettings, toggleSetting } from 'panel/actions';
import { getStatsConfig } from 'panel/actions/stats';
import { getLogsConfig } from 'panel/actions/queryLogs';
import { getFilteringStatus } from 'panel/actions/filtering';
import { StatsConfig } from './StatsConfig/StatsConfig';
import { LogsConfig } from './LogsConfig';
import { FiltersConfig } from './FiltersConfig';
import { getSafeSearchProviderTitle } from './helpers';
import { SwitchGroup } from './SettingsGroup';
const SETTINGS = {
safebrowsing: {
enabled: false,
title: intl.getMessage('settings_browsing_security'),
subtitle: intl.getMessage('settings_browsing_security_desc'),
},
parental: {
enabled: false,
title: intl.getMessage('settings_parental_control'),
subtitle: intl.getMessage('settings_parental_control_desc'),
},
};
export const Settings = () => {
const dispatch = useDispatch();
const settings = useSelector((state: RootState) => state.settings, shallowEqual);
const stats = useSelector((state: RootState) => state.stats, shallowEqual);
const queryLogs = useSelector((state: RootState) => state.queryLogs, shallowEqual);
const filtering = useSelector((state: RootState) => state.filtering, shallowEqual);
useEffect(() => {
batch(() => {
dispatch(initSettings());
dispatch(getStatsConfig());
dispatch(getFilteringStatus());
dispatch(getLogsConfig());
});
}, []);
const handleSettingToggle =
(key: keyof typeof SETTINGS) => (e: React.ChangeEvent<HTMLInputElement>) =>
dispatch(toggleSetting(key, !e.target.checked));
const renderSettings = (settingsList?: SettingsData['settingsList']) =>
settingsList
? (Object.keys(SETTINGS) as Array<keyof typeof SETTINGS>).map((key) => {
const { title, subtitle } = SETTINGS[key];
const enabled = Boolean(settingsList[key]?.enabled);
return (
<div key={key}>
<SwitchGroup
title={title}
description={subtitle}
id={String(key)}
checked={enabled}
onChange={handleSettingToggle(key)}
/>
</div>
);
})
: null;
const renderSafeSearch = () => {
const safesearch = settings.settingsList?.safesearch;
if (!safesearch) {
return null;
}
const { enabled, ...searches } = safesearch;
type SafeSearchConfigShape = Record<string, boolean> & { enabled: boolean };
const onSafeSearchEnabledChange =
(e: React.ChangeEvent<HTMLInputElement>) => {
const payload = { ...safesearch, enabled: e.target.checked } as SafeSearchConfigShape;
dispatch(toggleSetting('safesearch', payload));
};
const onProviderChange =
(searchKey: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
const payload = {
...safesearch,
[searchKey]: e.target.checked,
} as SafeSearchConfigShape;
dispatch(toggleSetting('safesearch', payload));
};
return (
<SwitchGroup
id="safesearch"
title={intl.getMessage('settings_safe_search')}
description={intl.getMessage('settings_safe_search_desc')}
checked={enabled}
onChange={onSafeSearchEnabledChange}>
<div>
{Object.keys(searches).map((searchKey) => (
<div key={searchKey} className={theme.form.checkbox}>
<Checkbox
id={searchKey}
checked={searches[searchKey]}
disabled={!enabled}
onChange={onProviderChange(searchKey)}>
{getSafeSearchProviderTitle(searchKey)}
</Checkbox>
</div>
))}
</div>
</SwitchGroup>
);
};
const isLoading = 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>
{isLoading && <PageLoader />}
{!isLoading && (
<>
<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}
/>
{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}
/>
<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}
/>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,50 @@
import React, { ReactNode } from 'react';
import cn from 'clsx';
import { Radio } from 'panel/common/controls/Radio';
import theme from 'panel/lib/theme';
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={cn(s.title, theme.text.t2, theme.text.semibold)}>{title}</div>
{description && <div className={cn(s.desc, theme.text.t3)}>{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,46 @@
import React, { ReactNode, useRef } from 'react';
import cn from 'clsx';
import { Switch } from 'panel/common/controls/Switch';
import theme from 'panel/lib/theme';
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 = () => {
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={cn(theme.text.t2, theme.text.semibold, s.title)}>{title}</div>
{description && <div className={cn(theme.text.t3, 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,40 @@
.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 {
color: var(--default-main-text);
}
.desc {
color: var(--default-description-text);
}
.input,
.content {
padding: 8px 0;
}

View File

@@ -0,0 +1,140 @@
import React, { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Button } from 'panel/common/ui/Button';
import intl from 'panel/common/intl';
import theme from 'panel/lib/theme';
import { getIntervalTitle, getDefaultInterval } from '../helpers';
import { STATS_INTERVALS_DAYS, RETENTION_CUSTOM } from '../../../helpers/constants';
import { RadioGroup, SwitchGroup } from '../SettingsGroup';
import { IgnoredDomains } from '../IgnoredDomains';
import { RetentionCustomInput } from '../RetentionCustomInput';
export type FormValues = {
enabled: boolean;
interval: number;
customInterval?: number | null;
ignored: string;
ignore_enabled: boolean;
};
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: {
enabled: initialValues.enabled || false,
interval: getDefaultInterval(initialValues.customInterval, initialValues.interval),
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 (STATS_INTERVALS_DAYS.includes(intervalValue)) {
setValue('customInterval', null);
}
}, [intervalValue]);
// Focus is handled inside RetentionCustomInput
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,
})),
]}>
<RetentionCustomInput
control={control}
processing={processing}
intervalValue={intervalValue}
intervals={STATS_INTERVALS_DAYS}
inputId="stats_config_custom_interval"
inputLabel={intl.getMessage('settings_statistics_retention_hours')}
placeholder={intl.getMessage('settings_rotation_placeholder')}
/>
</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,116 @@
import React, { useState } from 'react';
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';
import { resetStats, setStatsConfig } from 'panel/actions/stats';
import { useDispatch } from 'react-redux';
import { Form, FormValues } from './Form';
export type StatsConfigPayload = {
enabled: boolean;
ignored: string[];
interval: number;
};
type Props = {
interval: number;
customInterval?: number;
ignored: string[];
enabled: boolean;
processing: boolean;
processingReset: boolean;
};
export const StatsConfig = ({
interval: prevInterval,
customInterval,
ignored,
enabled,
processing,
processingReset,
}: Props) => {
const dispatch = useDispatch();
const [openClearDialog, setOpenClearDialog] = useState(false);
const [confirmConfig, setConfirmConfig] = useState<StatsConfigPayload | null>(null);
const handleClear = () => {
setOpenClearDialog(true);
};
const handleClose = () => {
setOpenClearDialog(false);
};
const handleClearConfirm = () => {
dispatch(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;
}
dispatch(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={() => {
dispatch(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,44 @@
import intl from 'panel/common/intl';
import { HOUR, DAY, RETENTION_CUSTOM } from 'panel/helpers/constants';
import { captitalizeWords } from '../../helpers/helpers';
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));
};
export const getIntervalTitle = (intervalMs: number) => {
if (intervalMs === RETENTION_CUSTOM) {
return intl.getMessage('settings_custom');
}
return formatIntervalText(intervalMs);
};
export const getDefaultInterval = (customInterval?: number, interval?: number) => {
if (customInterval && customInterval > 0) {
return RETENTION_CUSTOM;
}
return interval || DAY;
};
const SAFESEARCH_TITLES = {
bing: 'Bing',
duckduckgo: 'DuckDuckGo',
ecosia: 'Ecosia',
google: 'Google',
pixabay: 'Pixabay',
yandex: 'Yandex',
youtube: 'YouTube',
} as const;
export const getSafeSearchProviderTitle = (key: string) => {
return SAFESEARCH_TITLES[key] ?? captitalizeWords(key);
};

View File

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

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,31 @@
.toasts {
position: fixed;
right: 16px;
width: 100%;
max-width: 345px;
bottom: 24px;
z-index: 105;
@media (min-width: 768px) {
right: 24px;
max-width: 400px;
}
}
.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

@@ -1,18 +1,19 @@
import React, { useEffect, useState } from 'react';
import { Trans } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { Icon } from 'panel/common/ui/Icon';
import cn from 'clsx';
import { TOAST_TIMEOUTS } from '../../helpers/constants';
import { removeToast } from '../../actions';
import s from './styles.module.pcss';
interface ToastProps {
id: string;
message: string;
type: string;
options?: object;
}
const Toast = ({ id, message, type, options }: ToastProps) => {
const Toast = ({ id, message, type }: ToastProps) => {
const dispatch = useDispatch();
const [timerId, setTimerId] = useState(null);
@@ -30,28 +31,10 @@ const Toast = ({ id, message, type, options }: ToastProps) => {
}, []);
return (
<div
className={`toast toast--${type}`}
onMouseOver={clearRemoveToastTimeout}
onMouseOut={setRemoveToastTimeout}>
<p className="toast__content">
<Trans i18nKey={message} {...options} />
</p>
<div className={s.toast} onMouseOver={clearRemoveToastTimeout} onMouseOut={setRemoveToastTimeout}>
<Icon icon={type === 'success' ? 'check' : 'attention'} className={cn(s.icon, s[type])} />
<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 className={s.content}>{message}</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,38 @@
.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);
}
}

View File

@@ -1,45 +0,0 @@
import React, { ComponentProps, forwardRef, ReactNode } from 'react';
import clsx from 'clsx';
type Props = ComponentProps<'input'> & {
label?: string;
desc?: string;
leftAddon?: ReactNode;
rightAddon?: ReactNode;
error?: string;
trimOnBlur?: boolean;
};
export const Input = forwardRef<HTMLInputElement, Props>(
({ name, label, desc, className, leftAddon, rightAddon, error, trimOnBlur, onBlur, ...rest }, ref) => (
<div className={clsx('form-group', { '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>}
<div className="input-group">
{leftAddon && <div>{leftAddon}</div>}
<input
className={clsx('form-control', { 'is-invalid': !!error }, className)}
ref={ref}
onBlur={(e) => {
if (trimOnBlur) {
e.target.value = e.target.value.trim();
rest.onChange(e);
}
if (onBlur) {
onBlur(e);
}
}}
{...rest}
/>
{rightAddon && <div>{rightAddon}</div>}
</div>
{error && <div className="form__message form__message--error mt-1">{error}</div>}
</div>
),
);
Input.displayName = 'Input';

View File

@@ -1,27 +0,0 @@
import React, { ComponentProps, forwardRef } from 'react';
import clsx from 'clsx';
type SelectProps = ComponentProps<'select'> & {
label?: string;
error?: string;
};
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ name, label, className, error, children, ...rest }, ref) => (
<div className={clsx('form-group', { 'has-error': !!error })}>
{label && (
<label className="form__label" htmlFor={name}>
{label}
</label>
)}
<div className="input-group">
<select className={clsx('form-control custom-select', className)} ref={ref} {...rest}>
{children}
</select>
</div>
{error && <div className="form__message form__message--error mt-1">{error}</div>}
</div>
),
);
Select.displayName = 'Select';

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 = 10000;
export const TOAST_TIMEOUTS = {
[TOAST_TYPES.SUCCESS]: SUCCESS_TOAST_TIMEOUT,

View File

@@ -1,6 +1,5 @@
import 'url-polyfill';
import dateParse from 'date-fns/parse';
import dateFormat from 'date-fns/format';
import { parse as dateParse, format as dateFormat } from 'date-fns';
import round from 'lodash/round';
import axios from 'axios';
import i18n from 'i18next';

View File

@@ -5,6 +5,7 @@ export const LOCAL_STORAGE_KEYS = {
CLIENTS_PAGE_SIZE: 'clients_page_size',
REWRITES_PAGE_SIZE: 'rewrites_page_size',
AUTO_CLIENTS_PAGE_SIZE: 'auto_clients_page_size',
LANGUAGE: 'language',
};
export const LocalStorageHelper = {

View File

@@ -148,15 +148,9 @@ export type SettingsData = {
settingsList?: {
parental: {
enabled: boolean;
order: number;
subtitle: string;
title: string;
};
safebrowsing: {
enabled: boolean;
order: number;
subtitle: string;
title: string;
};
safesearch: Record<string, boolean>;
};
@@ -213,6 +207,7 @@ export type StatsData = {
enabled: boolean;
topUpstreamsAvgTime: { name: string; count: number }[];
topUpstreamsResponses: { name: string; count: number }[];
ignored: string[];
};
export type ClientsData = {
@@ -365,6 +360,7 @@ export type QueryLogsData = {
isDetailed: boolean;
isEntireLog: boolean;
customInterval: any;
ignored: string[];
};
export type ServicesData = {
@@ -566,6 +562,7 @@ export const initialState: RootState = {
isDetailed: true,
isEntireLog: false,
customInterval: null,
ignored: [],
},
rewrites: {
processing: true,
@@ -612,6 +609,7 @@ export const initialState: RootState = {
enabled: true,
topUpstreamsAvgTime: [],
topUpstreamsResponses: [],
ignored: [],
},
toasts: { notices: [] },
};

View File

@@ -1,5 +1,6 @@
import React from 'react';
import theme from 'panel/lib/theme';
import { getIpList, getDnsAddress, getWebAddress } from '../../helpers/helpers';
import { ALL_INTERFACES_IP } from '../../helpers/constants';
import { InstallInterface } from '../../initialState';
@@ -19,7 +20,7 @@ const renderItem = ({ ip, port, isDns }: renderItemProps) => {
{isDns ? (
<strong>{dnsAddress}</strong>
) : (
<a href={webAddress} target="_blank" rel="noopener noreferrer">
<a href={webAddress} target="_blank" className={theme.link.link} rel="noopener noreferrer">
{webAddress}
</a>
)}

View File

@@ -1,9 +1,9 @@
import React from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import { Input } from 'panel/common/controls/Input';
import Controls from './Controls';
import { validatePasswordLength, validateRequiredValue } from '../../helpers/validators';
import { Input } from '../../components/ui/Controls/Input';
type AuthFormValues = {
username: string;
@@ -60,10 +60,10 @@ export const Auth = ({ onAuthSubmit }: Props) => {
<Input
{...field}
type="text"
data-testid="install_username"
id="install_username"
label={t('install_auth_username')}
placeholder={t('install_auth_username_enter')}
error={fieldState.error?.message}
errorMessage={fieldState.error?.message}
autoComplete="username"
/>
)}
@@ -84,10 +84,10 @@ export const Auth = ({ onAuthSubmit }: Props) => {
<Input
{...field}
type="password"
data-testid="install_password"
id="install_password"
label={t('install_auth_password')}
placeholder={t('install_auth_password_enter')}
error={fieldState.error?.message}
errorMessage={fieldState.error?.message}
autoComplete="new-password"
/>
)}
@@ -108,10 +108,10 @@ export const Auth = ({ onAuthSubmit }: Props) => {
<Input
{...field}
type="password"
data-testid="install_confirm_password"
id="install_confirm_password"
label={t('install_auth_confirm')}
placeholder={t('install_auth_confirm')}
error={fieldState.error?.message}
errorMessage={fieldState.error?.message}
autoComplete="new-password"
/>
)}

View File

@@ -2,6 +2,7 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { Trans } from 'react-i18next';
import { Button } from 'panel/common/ui/Button';
import * as actionCreators from '../../actions/install';
interface ControlsProps {
@@ -33,13 +34,15 @@ class Controls extends Component<ControlsProps> {
case 2:
case 3:
return (
<button
data-testid="install_back"
<Button
id="install_back"
type="button"
className="btn btn-secondary btn-lg setup__button"
size="small"
variant="secondary"
className="setup__button"
onClick={this.props.prevStep}>
<Trans>back</Trans>
</button>
</Button>
);
default:
return false;
@@ -52,44 +55,52 @@ class Controls extends Component<ControlsProps> {
switch (step) {
case 1:
return (
<button
data-testid="install_get_started"
<Button
id="install_get_started"
type="button"
className="btn btn-success btn-lg setup__button"
onClick={nextStep}>
onClick={nextStep}
size="small"
variant="primary"
className="setup__button">
<Trans>get_started</Trans>
</button>
</Button>
);
case 2:
case 3:
return (
<button
data-testid="install_next"
<Button
id="install_next"
type="submit"
className="btn btn-success btn-lg setup__button"
size="small"
variant="primary"
className="setup__button"
disabled={invalid || pristine || install.processingSubmit}>
<Trans>next</Trans>
</button>
</Button>
);
case 4:
return (
<button
data-testid="install_next"
<Button
id="install_next"
type="button"
className="btn btn-success btn-lg setup__button"
size="small"
variant="primary"
className="setup__button"
onClick={nextStep}>
<Trans>next</Trans>
</button>
</Button>
);
case 5:
return (
<button
data-testid="install_open_dashboard"
<Button
id="install_open_dashboard"
type="button"
className="btn btn-success btn-lg setup__button"
size="small"
variant="primary"
className="setup__button"
onClick={() => this.props.openDashboard(ip, port)}>
<Trans>open_dashboard</Trans>
</button>
</Button>
);
default:
return false;
@@ -101,10 +112,8 @@ class Controls extends Component<ControlsProps> {
return (
<div className="setup__nav">
<div className="btn-list">
{this.renderPrevButton(install.step)}
{this.renderNextButton(install.step)}
</div>
{this.renderPrevButton(install.step)}
{this.renderNextButton(install.step)}
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { useForm, Controller } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import i18n from 'i18next';
import { Input } from 'panel/common/controls/Input';
import Controls from './Controls';
import AddressList from './AddressList';
@@ -20,8 +21,6 @@ import {
import { validateRequiredValue } from '../../helpers/validators';
import { InstallInterface } from '../../initialState';
import { Input } from '../../components/ui/Controls/Input';
import { Select } from '../../components/ui/Controls/Select';
import { toNumber } from '../../helpers/form';
const validateInstallPort = (value: number) => {
@@ -253,12 +252,12 @@ export const Settings = ({ handleSubmit, handleFix, validateForm, config, interf
name="web.ip"
control={control}
render={({ field }) => (
<Select {...field} data-testid="install_web_ip">
<select {...field} id="install_web_ip">
<option value={ALL_INTERFACES_IP}>
{t('install_settings_all_interfaces')}
</option>
{renderInterfaces(interfaces)}
</Select>
</select>
)}
/>
</div>
@@ -282,9 +281,9 @@ export const Settings = ({ handleSubmit, handleFix, validateForm, config, interf
<Input
{...field}
type="number"
data-testid="install_web_port"
id="install_web_port"
placeholder={STANDARD_WEB_PORT.toString()}
error={fieldState.error?.message}
errorMessage={fieldState.error?.message}
onChange={(e) => {
const { value } = e.target;
field.onChange(toNumber(value));
@@ -302,7 +301,7 @@ export const Settings = ({ handleSubmit, handleFix, validateForm, config, interf
{isWebFixAvailable && (
<button
type="button"
data-testid="install_web_fix"
id="install_web_fix"
className="btn btn-secondary btn-sm ml-2"
onClick={() => handleAutofix('web')}>
<Trans>fix</Trans>
@@ -343,12 +342,12 @@ export const Settings = ({ handleSubmit, handleFix, validateForm, config, interf
name="dns.ip"
control={control}
render={({ field }) => (
<Select {...field} data-testid="install_dns_ip">
<select {...field} id="install_dns_ip">
<option value={ALL_INTERFACES_IP}>
{t('install_settings_all_interfaces')}
</option>
{renderInterfaces(interfaces)}
</Select>
</select>
)}
/>
</div>
@@ -373,8 +372,8 @@ export const Settings = ({ handleSubmit, handleFix, validateForm, config, interf
<Input
{...field}
type="number"
data-testid="install_dns_port"
error={fieldState.error?.message}
id="install_dns_port"
errorMessage={fieldState.error?.message}
placeholder={STANDARD_WEB_PORT.toString()}
onChange={(e) => {
const { value } = e.target;
@@ -394,7 +393,7 @@ export const Settings = ({ handleSubmit, handleFix, validateForm, config, interf
{isDnsFixAvailable && (
<button
type="button"
data-testid="install_dns_fix"
id="install_dns_fix"
className="btn btn-secondary btn-sm ml-2"
onClick={() => handleAutofix('dns')}>
<Trans>fix</Trans>

View File

@@ -52,7 +52,9 @@
}
.setup__nav {
text-align: center;
display: flex;
gap: 16px;
justify-content: center;
}
.setup__step {
@@ -130,9 +132,7 @@
}
.setup__button {
min-width: 120px;
padding-left: 30px;
padding-right: 30px;
max-width: 200px;
}
.setup__error {

View File

@@ -11,7 +11,7 @@
cursor: default;
&_lang {
max-height: 300px;
max-height: 420px;
max-width: 100%;
overflow: auto;

View File

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

View File

@@ -1,37 +1,27 @@
.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: 1024px) {
padding: 48px 24px 72px;
max-width: 632px;
}
@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 {
margin: 0;
@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;

View File

@@ -1,8 +1,9 @@
import React from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Input } from '../../components/ui/Controls/Input';
import { validateRequiredValue } from '../../helpers/validators';
import { validateRequiredValue } from 'panel/helpers/validators';
import { Input } from 'panel/common/controls/Input';
import { Button } from 'panel/common/ui/Button';
export type LoginFormValues = {
username: string;
@@ -39,14 +40,13 @@ const Form = ({ onSubmit, processing }: LoginFormProps) => {
render={({ field, fieldState }) => (
<Input
{...field}
data-testid="username"
id="username"
type="text"
label={t('username_label')}
placeholder={t('username_placeholder')}
error={fieldState.error?.message}
errorMessage={fieldState.error?.message}
autoComplete="username"
autoCapitalize="none"
disabled={processing}
/>
)}
/>
@@ -60,26 +60,21 @@ const Form = ({ onSubmit, processing }: LoginFormProps) => {
render={({ field, fieldState }) => (
<Input
{...field}
data-testid="password"
id="password"
type="password"
label={t('password_label')}
placeholder={t('password_placeholder')}
error={fieldState.error?.message}
errorMessage={fieldState.error?.message}
autoComplete="current-password"
disabled={processing}
/>
)}
/>
</div>
<div className="form-footer">
<button
data-testid="sign_in"
type="submit"
className="btn btn-success btn-block"
disabled={processing || !isValid}>
<Button id="sign_in" type="submit" variant="primary" size="small" disabled={processing || !isValid}>
{t('sign_in')}
</button>
</Button>
</div>
</div>
</form>

View File

@@ -38,6 +38,12 @@
letter-spacing: 0;
}
.login__link {
margin-top: 16px;
border: 0;
background: transparent;
}
@media screen and (min-width: 992px) {
.login__message {
position: absolute;

View File

@@ -1,14 +1,16 @@
import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Trans } from 'react-i18next';
import cn from 'clsx';
import theme from 'panel/lib/theme';
import * as actionCreators from '../../actions/login';
import Toasts from '../../components/Toasts';
import Form, { LoginFormValues } from './Form';
import { LoginState } from '../../initialState';
import './Login.css';
import { LoginState } from '../../initialState';
export const Login = () => {
const dispatch = useDispatch();
@@ -29,7 +31,7 @@ export const Login = () => {
<Form onSubmit={handleSubmit} processing={processingLogin} />
<div className="login__info">
<button type="button" className="btn btn-link login__link" onClick={toggleText}>
<button type="button" className={cn(theme.link.link, 'login__link')} onClick={toggleText}>
<Trans>forgot_password</Trans>
</button>
@@ -41,7 +43,9 @@ export const Login = () => {
href="https://github.com/AdguardTeam/AdGuardHome/wiki/Configuration#password-reset"
key="0"
target="_blank"
rel="noopener noreferrer">
rel="noopener noreferrer"
className={theme.link.link}
>
link
</a>,
]}>

View File

@@ -163,6 +163,10 @@ const dashboard = handleActions(
theme: payload.theme,
processingProfile: false,
}),
[actions.changeLanguageSuccess.toString()]: (state, { payload }: any) => ({
...state,
language: payload.language,
}),
[actions.changeThemeSuccess.toString()]: (state, { payload }: any) => ({
...state,
theme: payload.theme,
@@ -188,6 +192,7 @@ const dashboard = handleActions(
supportedTags: [],
name: '',
theme: undefined,
language: undefined,
checkUpdateFlag: false,
},
);

View File

@@ -49,7 +49,7 @@ const meta: Meta<typeof Switch> = {
control: 'text',
description: 'Additional CSS class for the wrapper element',
},
handleChange: { action: 'changed' },
onChange: { action: 'changed' },
},
};

View File

@@ -1,34 +1,15 @@
import { test, expect } from '@playwright/test';
import { test } from '@playwright/test';
import { ADMIN_USERNAME, ADMIN_PASSWORD } from '../constants';
test.describe('Control Panel', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login.html');
await page.getByTestId('username').click();
await page.getByTestId('username').fill(ADMIN_USERNAME);
await page.getByTestId('password').click();
await page.getByTestId('password').fill(ADMIN_PASSWORD);
await page.locator('#username').click();
await page.locator('#username').fill(ADMIN_USERNAME);
await page.locator('#password').click();
await page.locator('#password').fill(ADMIN_PASSWORD);
await page.keyboard.press('Tab');
await page.getByTestId('sign_in').click();
await page.locator('#sign_in').click();
await page.waitForURL((url) => !url.href.endsWith('/login.html'));
});
test('should sign out successfully', async ({ page }) => {
await page.getByTestId('sign_out').click();
await page.waitForURL((url) => url.href.endsWith('/login.html'));
await expect(page.getByTestId('sign_in')).toBeVisible();
});
test('should change theme to dark and then light', async ({ page }) => {
await page.getByTestId('theme_dark').click();
await expect(page.locator('body[data-theme="dark"]')).toBeVisible();
await page.getByTestId('theme_light').click();
await expect(page.locator('body:not([data-theme="dark"])')).toBeVisible();
});
});

View File

@@ -1,64 +0,0 @@
import { test, expect } from '@playwright/test';
import { ADMIN_PASSWORD, ADMIN_USERNAME } from '../constants';
import { getDHCPConfig } from '../helpers/network';
const dhcpConfig = getDHCPConfig();
const INTERFACE_NAME = dhcpConfig.interfaceName;
const RANGE_START = dhcpConfig.rangeStart;
const RANGE_END = dhcpConfig.rangeEnd;
const SUBNET_MASK = dhcpConfig.subnetMask;
const LEASE_TIME = '86400';
test.describe('DHCP Configuration', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login.html');
await page.getByTestId('username').click();
await page.getByTestId('username').fill(ADMIN_USERNAME);
await page.getByTestId('password').click();
await page.getByTestId('password').fill(ADMIN_PASSWORD);
await page.keyboard.press('Tab');
await page.getByTestId('sign_in').click();
await page.waitForURL((url) => !url.href.endsWith('/login.html'));
await page.goto(`/#dhcp`);
});
test('should select the correct DHCP interface', async ({ page }) => {
await page.getByTestId('interface_name').selectOption(INTERFACE_NAME);
expect(await page.locator('select[name="interface_name"]').inputValue()).toBe(INTERFACE_NAME);
});
test('should configure DHCP IPv4 settings correctly', async ({ page }) => {
await page.getByTestId('interface_name').selectOption(INTERFACE_NAME);
await page.getByTestId('v4_gateway_ip').click();
await page.getByTestId('v4_gateway_ip').fill('192.168.1.99');
await page.getByTestId('v4_subnet_mask').click();
await page.getByTestId('v4_subnet_mask').fill(SUBNET_MASK);
await page.getByTestId('v4_range_start').click();
await page.getByTestId('v4_range_start').fill(RANGE_START);
await page.getByTestId('v4_range_end').click();
await page.getByTestId('v4_range_end').fill(RANGE_END);
await page.getByTestId('v4_lease_duration').click();
await page.getByTestId('v4_lease_duration').fill(LEASE_TIME);
await page.getByTestId('v4_save').click();
});
test('should show error for invalid DHCP IPv4 range', async ({ page }) => {
await page.getByTestId('interface_name').selectOption(INTERFACE_NAME);
await page.getByTestId('v4_range_start').click();
await page.getByTestId('v4_range_start').fill(RANGE_END);
await page.getByTestId('v4_range_end').click();
await page.getByTestId('v4_range_end').fill(RANGE_START);
await page.keyboard.press('Tab');
expect(await page.getByText('Must be greater than range').isVisible()).toBe(true);
});
test('should show error for invalid DHCP IPv4 address', async ({ page }) => {
await page.getByTestId('interface_name').selectOption(INTERFACE_NAME);
await page.getByTestId('v4_gateway_ip').click();
await page.getByTestId('v4_gateway_ip').fill('192.168.1.200s');
await page.keyboard.press('Tab');
expect(await page.getByText('Invalid IPv4 address').isVisible()).toBe(true);
});
});

View File

@@ -1,52 +0,0 @@
import { test, expect, type Page } from '@playwright/test';
import { ADMIN_USERNAME, ADMIN_PASSWORD } from '../constants';
test.describe('DNS Settings', () => {
test.beforeEach(async ({ page }) => {
// Login before each test
await page.goto('/login.html');
await page.getByTestId('username').click();
await page.getByTestId('username').fill(ADMIN_USERNAME);
await page.getByTestId('password').click();
await page.getByTestId('password').fill(ADMIN_PASSWORD);
await page.keyboard.press('Tab');
await page.getByTestId('sign_in').click();
await page.waitForURL((url) => !url.href.endsWith('/login.html'));
});
const runDNSSettingsTest = async (page: Page, address: string) => {
await page.goto('/#dns');
const currentDns = await page.getByTestId('upstream_dns').inputValue();
await page.getByTestId('upstream_dns').fill(address);
await page.getByTestId('dns_upstream_test').click();
await page.waitForTimeout(2000);
await expect(page.getByTestId('upstream_dns')).toHaveValue(address);
await page.getByTestId('upstream_dns').fill(currentDns);
await page.getByTestId('dns_upstream_save').click({ force: true });
};
test('test for Default DNS', async ({ page }) => {
await runDNSSettingsTest(page, 'https://dns10.quad9.net/dns-query');
});
test('test for Plain DNS', async ({ page }) => {
await runDNSSettingsTest(page, '94.140.14.140');
});
test('test for DNS-over-HTTPS', async ({ page }) => {
await runDNSSettingsTest(page, 'https://unfiltered.adguard-dns.com/dns-query');
});
test('test for DNS-over-TLS', async ({ page }) => {
await runDNSSettingsTest(page, 'tls://unfiltered.adguard-dns.com');
});
test('test for DNS-over-QUIC', async ({ page }) => {
await runDNSSettingsTest(page, 'quic://unfiltered.adguard-dns.com');
});
});

View File

@@ -1,73 +0,0 @@
import { test, expect, type Page } from '@playwright/test';
import { execSync } from 'child_process';
import { ADMIN_USERNAME, ADMIN_PASSWORD } from '../constants';
test.describe('Filtering', () => {
test.beforeEach(async ({ page }) => {
// Login before each test
await page.goto('/login.html');
await page.getByTestId('username').click();
await page.getByTestId('username').fill(ADMIN_USERNAME);
await page.getByTestId('password').click();
await page.getByTestId('password').fill(ADMIN_PASSWORD);
await page.keyboard.press('Tab');
await page.getByTestId('sign_in').click();
await page.waitForURL((url) => !url.href.endsWith('/login.html'));
});
const runTerminalCommand = (command: string) => {
try {
console.info(`Executing command: ${command}`);
const output = execSync(command, { encoding: 'utf-8', stdio: 'pipe' }).trim();
console.info('Command executed successfully.');
console.debug(`Command output:\n${output}`);
return output;
} catch (error: any) {
console.error(`Command execution failed with error:\n${error.message}`);
throw new Error(`Failed to execute command: ${command}\nError: ${error.message}`);
}
}
const runCustomRuleTest = async (page: Page, domain_to_block: string) => {
await page.goto('/#custom_rules');
await page.getByTestId('custom_rule_textarea').fill(domain_to_block);
await page.getByTestId('apply_custom_rule').click();
const nslookupBlockedResult = await runTerminalCommand(`nslookup ${domain_to_block} 127.0.0.1`).toString();
console.info(`nslookup blocked CNAME result: '${nslookupBlockedResult}'`);
const currentRules = await page.getByTestId('custom_rule_textarea').inputValue();
console.debug(`Current rules before removal:\n${currentRules}`);
if (currentRules.includes(domain_to_block)) {
const updatedRules = currentRules
.split('\n')
.filter((line) => line.trim() !== domain_to_block.trim())
.join('\n');
await page.getByTestId('custom_rule_textarea').fill(updatedRules);
console.info(`Rule '${domain_to_block}' removed successfully.`);
console.info('Applying the updated filtering rules after removal.');
await page.getByTestId('apply_custom_rule').click();
await page.waitForLoadState('domcontentloaded');
console.info(`Filtering rules successfully updated after removing '${domain_to_block}'.`);
} else {
console.warn(`Rule '${domain_to_block}' not found. No changes were made.`);
}
const nslookupUnblockedResult = await runTerminalCommand(`nslookup ${domain_to_block} 127.0.0.1`).toString();
console.info(`nslookup unblocked CNAME result: '${nslookupUnblockedResult}'`);
};
test('Test blocking rule for apple.com', async ({ page }) => {
await runCustomRuleTest(page, 'apple.com');
});
});

View File

@@ -5,19 +5,19 @@ import { ADMIN_USERNAME, ADMIN_PASSWORD } from '../constants';
test.describe('General Settings', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login.html');
await page.getByTestId('username').click();
await page.getByTestId('username').fill(ADMIN_USERNAME);
await page.getByTestId('password').click();
await page.getByTestId('password').fill(ADMIN_PASSWORD);
await page.locator('#username').click();
await page.locator('#username').fill(ADMIN_USERNAME);
await page.locator('#password').click();
await page.locator('#password').fill(ADMIN_PASSWORD);
await page.keyboard.press('Tab');
await page.getByTestId('sign_in').click();
await page.locator('#sign_in').click();
await page.waitForURL((url) => !url.href.endsWith('/login.html'));
});
test('should toggle browsing security feature and verify DNS changes', async ({ page }) => {
await page.goto('/#settings');
const browsingSecurity = await page.getByTestId('safebrowsing');
const browsingSecurity = await page.locator('#safebrowsing');
const browsingSecurityLabel = await browsingSecurity.locator('xpath=following-sibling::*[1]');
const initialState = await browsingSecurity.isChecked();
@@ -45,7 +45,7 @@ test.describe('General Settings', () => {
test('should toggle parental control feature and verify DNS changes', async ({ page }) => {
await page.goto('/#settings');
const parentalControl = page.getByTestId('parental');
const parentalControl = page.locator('#parental');
const parentalControlLabel = await parentalControl.locator('xpath=following-sibling::*[1]');
const initialState = await parentalControl.isChecked();
@@ -73,7 +73,7 @@ test.describe('General Settings', () => {
test('should toggle safe search feature', async ({ page }) => {
await page.goto('/#settings');
const safeSearch = page.getByTestId('safesearch');
const safeSearch = page.locator('#safesearch');
const safeSearchLabel = await safeSearch.locator('xpath=following-sibling::*[1]');
const initialState = await safeSearch.isChecked();

View File

@@ -10,16 +10,16 @@ async function globalSetup(config: FullConfig) {
try {
await page.goto('/');
await page.getByTestId('install_get_started').click();
await page.getByTestId('install_web_port').fill(PORT.toString());
await page.getByTestId('install_next').click();
await page.getByTestId('install_username').fill(ADMIN_USERNAME);
await page.getByTestId('install_password').fill(ADMIN_PASSWORD);
await page.getByTestId('install_confirm_password').click();
await page.getByTestId('install_confirm_password').fill(ADMIN_PASSWORD);
await page.getByTestId('install_next').click();
await page.getByTestId('install_next').click();
await page.getByTestId('install_open_dashboard').click();
await page.locator('#install_get_started').click();
await page.locator('#install_web_port').fill(PORT.toString());
await page.locator('#install_next').click();
await page.locator('#install_username').fill(ADMIN_USERNAME);
await page.locator('#install_password').fill(ADMIN_PASSWORD);
await page.locator('#install_confirm_password').click();
await page.locator('#install_confirm_password').fill(ADMIN_PASSWORD);
await page.locator('#install_next').click();
await page.locator('#install_next').click();
await page.locator('#install_open_dashboard').click();
await page.waitForURL((url) => !url.href.endsWith('/install.html'));
} catch (error) {
console.error('Error during global setup:', error);

View File

@@ -5,12 +5,12 @@ import { ADMIN_PASSWORD, ADMIN_USERNAME } from '../constants';
test.describe('Login', () => {
test('should successfully log in with valid credentials', async ({ page }) => {
await page.goto('/login.html');
await page.getByTestId('username').click();
await page.getByTestId('username').fill(ADMIN_USERNAME);
await page.getByTestId('password').click();
await page.getByTestId('password').fill(ADMIN_PASSWORD);
await page.locator('#username').click();
await page.locator('#username').fill(ADMIN_USERNAME);
await page.locator('#password').click();
await page.locator('#password').fill(ADMIN_PASSWORD);
await page.keyboard.press('Tab');
await page.getByTestId('sign_in').click();
await page.locator('#sign_in').click();
await page.waitForURL((url) => !url.href.endsWith('/login.html'));
});
});

View File

@@ -1,124 +0,0 @@
import { test, expect } from '@playwright/test';
import { ADMIN_USERNAME, ADMIN_PASSWORD } from '../constants';
test.describe('QueryLog', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login.html');
await page.getByTestId('username').click();
await page.getByTestId('username').fill(ADMIN_USERNAME);
await page.getByTestId('password').click();
await page.getByTestId('password').fill(ADMIN_PASSWORD);
await page.keyboard.press('Tab');
await page.getByTestId('sign_in').click();
await page.waitForURL((url) => !url.href.endsWith('/login.html'));
});
test('Search of queryLog should work correctly', async ({ page }) => {
await page.route('/control/querylog', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
{
"data": [
{
"answer": [
{
"type": "A",
"value": "77.88.44.242",
"ttl": 294
},
{
"type": "A",
"value": "5.255.255.242",
"ttl": 294
},
{
"type": "A",
"value": "77.88.55.242",
"ttl": 294
}
],
"answer_dnssec": false,
"cached": false,
"client": "127.0.0.1",
"client_info": {
"whois": {},
"name": "localhost",
"disallowed_rule": "127.0.0.1",
"disallowed": false
},
"client_proto": "",
"elapsedMs": "78.163167",
"question": {
"class": "IN",
"name": "ya.ru",
"type": "A"
},
"reason": "NotFilteredNotFound",
"rules": [],
"status": "NOERROR",
"time": "2024-07-17T16:02:37.500662+02:00",
"upstream": "https://dns10.quad9.net:443/dns-query"
},
{
"answer": [
{
"type": "A",
"value": "77.88.55.242",
"ttl": 351
},
{
"type": "A",
"value": "77.88.44.242",
"ttl": 351
},
{
"type": "A",
"value": "5.255.255.242",
"ttl": 351
}
],
"answer_dnssec": false,
"cached": false,
"client": "127.0.0.1",
"client_info": {
"whois": {},
"name": "localhost",
"disallowed_rule": "127.0.0.1",
"disallowed": false
},
"client_proto": "",
"elapsedMs": "5051.070708",
"question": {
"class": "IN",
"name": "ya.ru",
"type": "A"
},
"reason": "NotFilteredNotFound",
"rules": [],
"status": "NOERROR",
"time": "2024-07-17T16:02:37.4983+02:00",
"upstream": "https://dns10.quad9.net:443/dns-query"
}
],
"oldest": "2024-07-17T16:02:37.4983+02:00"
}
),
});
});
await page.goto('/#logs');
await page.getByTestId('querylog_search').fill('127.0.0.1');
const [request] = await Promise.all([
page.waitForRequest((req) => req.url().includes('/control/querylog')),
]);
if (request) {
expect(request.url()).toContain('search=127.0.0.1');
expect(await page.getByTestId('querylog_cell').first().isVisible()).toBe(true);
}
});
});

View File

@@ -4,6 +4,7 @@
"jsx": "react",
"module": "ESNext",
"moduleResolution": "bundler",
"baseUrl": ".",
"allowArbitraryExtensions": true,
"resolveJsonModule": true,
"allowJs": true,

View File

@@ -33,7 +33,7 @@ function getAliasesFromTsconfig(tsconfigPath, resourcesPath) {
Object.entries(tsConfig.compilerOptions.paths).forEach(([alias, targetArr]) => {
if (alias.endsWith('/*')) {
const aliasName = alias.replace('/*', '');
let target = targetArr[0].replace('/*', '');
const target = targetArr[0].replace('/*', '');
aliases[aliasName] = path.resolve(resourcesPath, target);
}
});

View File

@@ -1,6 +1,7 @@
import { merge } from 'webpack-merge';
import yaml from 'js-yaml';
import fs from 'fs';
import { BASE_URL } from './constants.js';
import common from './webpack.common.js';

View File

@@ -1,4 +1,5 @@
import { merge } from 'webpack-merge';
/* eslint-disable-next-line import/extensions */
import common from './webpack.common.js';
export default merge(common, {