mirror of
https://git.vectorsigma.ru/public/AdGuardHome.git
synced 2026-08-03 19:19:21 +00:00
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 commit3f6d759b7fAuthor: Ildar Kamalov <ik@adguard.com> Date: Tue Aug 12 16:40:31 2025 +0300 fix placeholder commit507631ab71Author: Ildar Kamalov <ik@adguard.com> Date: Tue Aug 12 16:15:57 2025 +0300 use batch commite495a5585cAuthor: Ildar Kamalov <ik@adguard.com> Date: Tue Aug 12 16:13:13 2025 +0300 review fix commit2ba671b408Author: Ildar Kamalov <ik@adguard.com> Date: Tue Aug 12 10:45:00 2025 +0300 fix commit6f696238a0Author: Ildar Kamalov <ik@adguard.com> Date: Mon Aug 11 18:38:28 2025 +0300 fix language change commit8206f3f885Author: Ildar Kamalov <ik@adguard.com> Date: Mon Aug 11 14:54:58 2025 +0300 fix custom radio commit0920a51c4aAuthor: Ildar Kamalov <ik@adguard.com> Date: Mon Aug 11 14:30:26 2025 +0300 fix commitc7ca1e8eafAuthor: Ildar Kamalov <ik@adguard.com> Date: Mon Aug 11 14:25:16 2025 +0300 fix ids commit7ecd6426d9Author: Ildar Kamalov <ik@adguard.com> Date: Mon Aug 11 13:56:29 2025 +0300 fix constant import commit246f1277b8Author: Ildar Kamalov <ik@adguard.com> Date: Mon Aug 11 13:46:11 2025 +0300 fix version commit0224f567e6Author: Ildar Kamalov <ik@adguard.com> Date: Mon Aug 11 13:34:29 2025 +0300 fix version commit75c9caffbdAuthor: Ildar Kamalov <ik@adguard.com> Date: Mon Aug 11 13:14:01 2025 +0300 lint fix commitd0cf6ad5abAuthor: Ildar Kamalov <ik@adguard.com> Date: Mon Aug 11 12:40:49 2025 +0300 lint fix commit87eb68e519Author: Ildar Kamalov <ik@adguard.com> Date: Mon Aug 11 12:06:35 2025 +0300 use npm commit7df7d60ec9Author: Ildar Kamalov <ik@adguard.com> Date: Mon Aug 11 11:23:00 2025 +0300 rm unused commitc285ea978aAuthor: Ildar Kamalov <ik@adguard.com> Date: Mon Aug 11 10:42:39 2025 +0300 fix commitd00f1e12b3Author: Ildar Kamalov <ik@adguard.com> Date: Mon Aug 11 09:43:35 2025 +0300 fix ... and 2 more commits
This commit is contained in:
2
Makefile
2
Makefile
@@ -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
75
client_v2/.eslintrc.cjs
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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
17186
client_v2/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
10794
client_v2/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"client_settings": "Настройки клиентов"
|
||||
"privacy_policy": "Политика конфиденциальности"
|
||||
}
|
||||
|
||||
470
client_v2/src/__tests__/helpers.test.ts
Normal file
470
client_v2/src/__tests__/helpers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
box-shadow: 0 0 0 100px var(--pressed-page-background) inset;
|
||||
}
|
||||
}
|
||||
|
||||
&.large {
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
max-width: 100%;
|
||||
width: 720px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
.links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" />;
|
||||
6
client_v2/src/common/ui/Loader/PageLoader.tsx
Normal file
6
client_v2/src/common/ui/Loader/PageLoader.tsx
Normal 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" />;
|
||||
@@ -1,2 +1,3 @@
|
||||
export { InlineLoader } from './InlineLoader';
|
||||
export { Loader } from './Loader';
|
||||
export { PageLoader } from './PageLoader';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
80
client_v2/src/components/Settings/FiltersConfig.tsx
Normal file
80
client_v2/src/components/Settings/FiltersConfig.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { IgnoredDomains } from './IgnoredDomains';
|
||||
@@ -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;
|
||||
}
|
||||
155
client_v2/src/components/Settings/LogsConfig/Form.tsx
Normal file
155
client_v2/src/components/Settings/LogsConfig/Form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
119
client_v2/src/components/Settings/LogsConfig/LogsConfig.tsx
Normal file
119
client_v2/src/components/Settings/LogsConfig/LogsConfig.tsx
Normal 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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
1
client_v2/src/components/Settings/LogsConfig/index.ts
Normal file
1
client_v2/src/components/Settings/LogsConfig/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { LogsConfig } from './LogsConfig';
|
||||
68
client_v2/src/components/Settings/RetentionCustomInput.tsx
Normal file
68
client_v2/src/components/Settings/RetentionCustomInput.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
182
client_v2/src/components/Settings/Settings.tsx
Normal file
182
client_v2/src/components/Settings/Settings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
2
client_v2/src/components/Settings/SettingsGroup/index.ts
Normal file
2
client_v2/src/components/Settings/SettingsGroup/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { RadioGroup } from './RadioGroup';
|
||||
export { SwitchGroup } from './SwitchGroup';
|
||||
@@ -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;
|
||||
}
|
||||
140
client_v2/src/components/Settings/StatsConfig/Form.tsx
Normal file
140
client_v2/src/components/Settings/StatsConfig/Form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
116
client_v2/src/components/Settings/StatsConfig/StatsConfig.tsx
Normal file
116
client_v2/src/components/Settings/StatsConfig/StatsConfig.tsx
Normal 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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
1
client_v2/src/components/Settings/StatsConfig/index.ts
Normal file
1
client_v2/src/components/Settings/StatsConfig/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { StatsConfig } from './StatsConfig';
|
||||
44
client_v2/src/components/Settings/helpers.ts
Normal file
44
client_v2/src/components/Settings/helpers.ts
Normal 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);
|
||||
};
|
||||
1
client_v2/src/components/Settings/index.ts
Normal file
1
client_v2/src/components/Settings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Settings } from './Settings';
|
||||
@@ -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;
|
||||
}
|
||||
31
client_v2/src/components/Toasts/Toast.pcss
Normal file
31
client_v2/src/components/Toasts/Toast.pcss
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
38
client_v2/src/components/Toasts/styles.module.pcss
Normal file
38
client_v2/src/components/Toasts/styles.module.pcss
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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: [] },
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
cursor: default;
|
||||
|
||||
&_lang {
|
||||
max-height: 300px;
|
||||
max-height: 420px;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
|
||||
|
||||
30
client_v2/src/lib/theme/Form.module.pcss
Normal file
30
client_v2/src/lib/theme/Form.module.pcss
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.no_decoration {
|
||||
&.noDecoration {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
]}>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
"jsx": "react",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"baseUrl": ".",
|
||||
"allowArbitraryExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowJs": true,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user