mirror of
https://github.com/AdguardTeam/AdGuardHome.git
synced 2026-10-12 10:09:55 +00:00
Pull request 2697: AGDNS-4173 update DNS settings page
Squashed commit of the following:
commit 707f6b39c2e42f352605457e2357deaa48d57759
Author: Ildar Kamalov <[email protected]>
Date: Thu Jul 9 12:08:24 2026 +0300
fix logs and stats validation error
commit 852dfefc01cefc245d23a4ffc4ea46655f7b3298
Author: Ildar Kamalov <[email protected]>
Date: Thu Jul 9 10:20:28 2026 +0300
fix break word
commit d1190a28b5a318021d24d134716e3a69435b6af3
Author: Ildar Kamalov <[email protected]>
Date: Thu Jul 9 08:29:18 2026 +0300
fix validators
commit 1f751a4246b7cdf114b8aab7b0eb4bc2e1ed6c31
Author: Ildar Kamalov <[email protected]>
Date: Wed Jul 8 22:28:17 2026 +0300
fix validate message
commit 5be2f1b933df4f8d11c250383a53e00937aa5898
Author: Ildar Kamalov <[email protected]>
Date: Wed Jul 8 17:31:05 2026 +0300
fix
commit 0dcbb7d0cf2ad8c7d4682ea9e4359a1af1a0382e
Author: Ildar Kamalov <[email protected]>
Date: Wed Jul 8 17:22:24 2026 +0300
fix translations
commit 3fdb09ec7f9fea9442b184cf34f07370f955a3bc
Author: Ildar Kamalov <[email protected]>
Date: Wed Jul 8 16:58:01 2026 +0300
fix error messages
commit e68f7cb490624b8c51a260cceeda823934c0fc20
Author: Ildar Kamalov <[email protected]>
Date: Wed Jul 8 16:26:16 2026 +0300
fix file config modal
commit 8095b8a348e995c486711d7ce8103d31f8c24f48
Author: Ildar Kamalov <[email protected]>
Date: Wed Jul 8 16:15:10 2026 +0300
review fix
commit ce4f8c7e043516b29ee7a58dadb0fa46477e3499
Author: Ildar Kamalov <[email protected]>
Date: Wed Jul 8 15:28:42 2026 +0300
fix breadcrumbs
commit e498d43e4c8c4cfcb1b5c8cc4b1743b4ac33a248
Author: Ildar Kamalov <[email protected]>
Date: Wed Jul 8 14:56:12 2026 +0300
review fix
commit 4d23c86195a1574c34c284a2a7c8e676d8ff13d7
Author: Ildar Kamalov <[email protected]>
Date: Tue Jul 7 19:05:40 2026 +0300
fix
commit 283e58d26e220d5b2e1ef8f75ab7b5cb849d4bcf
Author: Ildar Kamalov <[email protected]>
Date: Tue Jul 7 18:58:59 2026 +0300
rm unused
commit 4e56a88d51388c4758704dc6910dcf7e2e2bd0ae
Author: Ildar Kamalov <[email protected]>
Date: Tue Jul 7 18:57:53 2026 +0300
fix const
commit 95b55fbce74fa59da3a0826739d661b9f239ecff
Merge: 4e3386a4a f49ee9fb7
Author: Ildar Kamalov <[email protected]>
Date: Tue Jul 7 18:52:12 2026 +0300
Merge branch 'ADG-10291' into AGDNS-4173
commit 4e3386a4a62f3f2060a7d042e56b109c5bd6e57f
Author: Ildar Kamalov <[email protected]>
Date: Tue Jul 7 18:50:00 2026 +0300
AGDNS-4173 update DNS settings page
commit 4cbf10e68a7cc97ce9bec4bc4f4eb7dddc279739
Author: Ildar Kamalov <[email protected]>
Date: Tue Jul 7 11:44:00 2026 +0300
fix value display
commit 5b9baa8afda019a2caacaed998d88f1eda191acb
Author: Ildar Kamalov <[email protected]>
Date: Mon Jul 6 18:38:43 2026 +0300
fix button color
commit 64a840d66083e0d2307d32de22f6c8f9998aeaba
Author: Ildar Kamalov <[email protected]>
Date: Mon Jul 6 18:37:40 2026 +0300
fix button color
commit 401cbf8dcfc4c189b696a763793cd0a05cfa6b0f
Author: Ildar Kamalov <[email protected]>
Date: Mon Jul 6 18:36:56 2026 +0300
fix button width
commit 4b3070a0e4759cddfc18eec1cd36de453140dcb0
Author: Ildar Kamalov <[email protected]>
Date: Mon Jul 6 16:03:00 2026 +0300
review fix
commit ebb5a5a675309bb0d6a33d217f400b79f50dda4a
Author: Ildar Kamalov <[email protected]>
Date: Mon Jul 6 15:27:44 2026 +0300
missing toast
commit 7fd5553b747daf6fee5ad6c88b26ca01a2971cfe
Author: Ildar Kamalov <[email protected]>
Date: Mon Jul 6 15:18:14 2026 +0300
fix ignored domains
commit 243713b77ee78d93f222c67c3aaea51595f04071
Author: Ildar Kamalov <[email protected]>
Date: Mon Jul 6 14:57:27 2026 +0300
fix title
commit 9e430938aeaf2261099be9f33a7b2f59695ec2f3
Author: Ildar Kamalov <[email protected]>
Date: Mon Jul 6 13:51:11 2026 +0300
review fix
... and 4 more commits
This commit is contained in:
@@ -1,49 +1,48 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import jscodeshift from 'jscodeshift';
|
||||
|
||||
const j = jscodeshift.withParser('tsx');
|
||||
import ts from 'typescript';
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx']);
|
||||
const TRANSLATION_METHODS = new Set(['getMessage', 'getPlural']);
|
||||
|
||||
const isIntlMethod = (callee) =>
|
||||
callee?.type === 'MemberExpression'
|
||||
&& !callee.computed
|
||||
&& callee.object?.type === 'Identifier'
|
||||
&& callee.object.name === 'intl'
|
||||
&& callee.property?.type === 'Identifier'
|
||||
&& TRANSLATION_METHODS.has(callee.property.name);
|
||||
const isIntlMethod = (node) => {
|
||||
if (!ts.isPropertyAccessExpression(node)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const getLiteralKey = (argument) => {
|
||||
if (!argument) {
|
||||
return (
|
||||
ts.isIdentifier(node.expression) &&
|
||||
node.expression.text === 'intl' &&
|
||||
ts.isIdentifier(node.name) &&
|
||||
TRANSLATION_METHODS.has(node.name.text)
|
||||
);
|
||||
};
|
||||
|
||||
const getLiteralKey = (node) => {
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (argument.type === 'StringLiteral') {
|
||||
return argument.value;
|
||||
if (ts.isStringLiteral(node)) {
|
||||
return node.text;
|
||||
}
|
||||
|
||||
if (argument.type === 'Literal' && typeof argument.value === 'string') {
|
||||
return argument.value;
|
||||
if (ts.isNoSubstitutionTemplateLiteral(node)) {
|
||||
return node.text;
|
||||
}
|
||||
|
||||
if (argument.type === 'TemplateLiteral' && argument.expressions.length === 0) {
|
||||
return argument.quasis[0]?.value.cooked ?? '';
|
||||
}
|
||||
|
||||
if (argument.type === 'ParenthesizedExpression') {
|
||||
return getLiteralKey(argument.expression);
|
||||
if (ts.isParenthesizedExpression(node)) {
|
||||
return getLiteralKey(node.expression);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const sortByFileAndLine = (left, right) =>
|
||||
left.filePath.localeCompare(right.filePath)
|
||||
|| left.line - right.line
|
||||
|| left.method.localeCompare(right.method);
|
||||
left.filePath.localeCompare(right.filePath) ||
|
||||
left.line - right.line ||
|
||||
left.method.localeCompare(right.method);
|
||||
|
||||
const toRelativePath = (filePath, rootDir) => {
|
||||
if (!rootDir) {
|
||||
@@ -55,38 +54,38 @@ const toRelativePath = (filePath, rootDir) => {
|
||||
return relativePath === '' ? '.' : relativePath;
|
||||
};
|
||||
|
||||
const formatNodeSource = (node) => j(node).toSource({ quote: 'single' });
|
||||
const formatNodeSource = (node, sourceFile) => sourceFile.text.slice(node.pos, node.end);
|
||||
|
||||
export const collectTranslationUsageFromSource = (source, filePath) => {
|
||||
const staticKeys = [];
|
||||
const dynamicUsages = [];
|
||||
|
||||
j(source)
|
||||
.find(j.CallExpression)
|
||||
.forEach((callPath) => {
|
||||
const { node } = callPath;
|
||||
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
|
||||
|
||||
if (!isIntlMethod(node.callee)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const method = node.callee.property.name;
|
||||
const line = node.loc?.start?.line ?? 0;
|
||||
const visit = (node) => {
|
||||
if (ts.isCallExpression(node) && isIntlMethod(node.expression)) {
|
||||
const method = node.expression.name.text;
|
||||
const pos = sourceFile.getLineAndCharacterOfPosition(node.pos);
|
||||
const line = pos.line + 1;
|
||||
const key = getLiteralKey(node.arguments[0]);
|
||||
|
||||
if (key !== null) {
|
||||
staticKeys.push({ filePath, key, line, method });
|
||||
|
||||
return;
|
||||
} else {
|
||||
dynamicUsages.push({
|
||||
expression: formatNodeSource(node, sourceFile),
|
||||
filePath,
|
||||
line,
|
||||
method,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
dynamicUsages.push({
|
||||
expression: formatNodeSource(node),
|
||||
filePath,
|
||||
line,
|
||||
method,
|
||||
});
|
||||
});
|
||||
// Always recurse into children to find nested intl.getMessage() calls
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
ts.forEachChild(sourceFile, visit);
|
||||
|
||||
return {
|
||||
dynamicUsages: dynamicUsages.sort(sortByFileAndLine),
|
||||
@@ -97,7 +96,9 @@ export const collectTranslationUsageFromSource = (source, filePath) => {
|
||||
export const auditTranslations = ({ localeMessages, usage }) => {
|
||||
const localeKeys = Object.keys(localeMessages);
|
||||
const usedStaticKeySet = new Set(usage.staticKeys.map((entry) => entry.key));
|
||||
const missingKeys = [...usedStaticKeySet].filter((key) => !Object.hasOwn(localeMessages, key)).sort();
|
||||
const missingKeys = [...usedStaticKeySet]
|
||||
.filter((key) => !Object.hasOwn(localeMessages, key))
|
||||
.sort();
|
||||
const unusedKeys = localeKeys.filter((key) => !usedStaticKeySet.has(key)).sort();
|
||||
|
||||
return {
|
||||
@@ -151,7 +152,9 @@ export const formatAuditReport = (report, { rootDir } = {}) => {
|
||||
} else {
|
||||
lines.push('Dynamic translation usages:');
|
||||
report.dynamicUsages.forEach((entry) => {
|
||||
lines.push(`- ${toRelativePath(entry.filePath, rootDir)}:${entry.line} ${entry.expression}`);
|
||||
lines.push(
|
||||
`- ${toRelativePath(entry.filePath, rootDir)}:${entry.line} ${entry.expression}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+146
-15
@@ -44,8 +44,9 @@
|
||||
"add_allowlist": "Add allowlist",
|
||||
"check_updates_btn": "Check for updates",
|
||||
"form_error_url_format": "Enter a valid URL or file path",
|
||||
"form_error_upstream_format": "Invalid upstream format on line %line%",
|
||||
"form_error_upstream_format_multi": "Invalid upstream format on lines %lines%",
|
||||
"form_error_format": "Invalid format",
|
||||
"form_error_format_line": "Invalid format on line %line%",
|
||||
"form_error_format_lines": "Invalid format on lines %lines%",
|
||||
"custom_rules": "Custom rules",
|
||||
"system_host_files": "System hosts files",
|
||||
"all_lists_up_to_date_toast": "All lists are already up-to-date",
|
||||
@@ -53,10 +54,10 @@
|
||||
"dns_server_configuration_saved_toast": "DNS server configuration saved",
|
||||
"dns_cache_configuration_saved_toast": "DNS cache configuration saved",
|
||||
"access_settings_saved_toast": "Access settings saved",
|
||||
"dns_test_ok_toast": "Specified DNS servers are working correctly",
|
||||
"dns_test_not_ok_toast": "Server \"%key%\": could not be used, please check that you've written it correctly",
|
||||
"dns_test_parsing_error_toast": "Section %section%: line %line%: could not be used, please check that you've written it correctly",
|
||||
"dns_test_warning_toast": "Upstream \"%key%\" does not respond to test requests and may not work properly",
|
||||
"dns_test_ok_toast": "All upstreams are operational",
|
||||
"dns_test_not_ok_toast": "Upstream %key% passed syntax check but failed the test. Verify the upstream settings",
|
||||
"dns_test_parsing_error_toast": "Failed to extract upstream name from line %number% in the %section% section. Check the line format",
|
||||
"dns_test_warning_toast": "Upstream %key% did not respond to the DNS query. Verify the upstream settings",
|
||||
"unblock": "Unblock",
|
||||
"block": "Block",
|
||||
"disallow_this_client": "Disallow this client",
|
||||
@@ -213,9 +214,8 @@
|
||||
"settings_domain_names": "Domain names, one per line",
|
||||
"settings_rule_syntax": "Rule syntax",
|
||||
"form_error_required": "Fill out this field",
|
||||
"form_value_length_common_error": "This value should be less than or equal to %max_length%",
|
||||
"form_value_value_between_error": "This value should be between %min_value% and %max_value%",
|
||||
"form_value_value_min_error": "This value should be %min_value% or higher",
|
||||
"form_value_value_from_error": "Enter a value from %min_value% and %max_value%",
|
||||
"form_value_value_min_error": "Enter a value of %min_value% or greater",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
@@ -276,19 +276,150 @@
|
||||
"encryption_confirm_clear_desc": "All encryption settings will be reset to their default values",
|
||||
"reset": "Reset",
|
||||
"confirm": "Confirm",
|
||||
"dns_settings": "DNS settings",
|
||||
"dns_access_settings_title": "Access settings",
|
||||
"dns_allowed_clients": "Allowed clients",
|
||||
"dns_allowed_clients_desc": "Only accepts requests from clients on this list",
|
||||
"dns_allowed_clients_desc_2": "To add a client, enter its <a>CIDR, IP address, or ClientID</a>",
|
||||
"dns_allowed_clients_label": "Allowed clients, one per line",
|
||||
"dns_allowed_clients_placeholder": "Enter allowed clients",
|
||||
"dns_blocking_mode": "Blocking mode",
|
||||
"dns_blocking_mode_custom_ip": "Custom IP address",
|
||||
"dns_blocking_mode_custom_ip_desc": "Responds with a manually set IP address",
|
||||
"dns_blocking_mode_default": "Default",
|
||||
"dns_blocking_mode_default_desc": "Responds with 0.0.0.0 for A records and :: for AAAA records if blocked by an Adblock-style rule. Responds with the IP address specified in the rule if blocked by a hosts-style rule",
|
||||
"dns_blocking_mode_desc": "Sets the response type to blocked requests",
|
||||
"dns_blocking_mode_ipv4_label": "Blocking IPv4",
|
||||
"dns_blocking_mode_ipv4_placeholder": "Enter IPv4 address",
|
||||
"dns_blocking_mode_ipv6_label": "Blocking IPv6",
|
||||
"dns_blocking_mode_ipv6_placeholder": "Enter IPv6 address",
|
||||
"dns_blocking_mode_null_ip": "Null IP address",
|
||||
"dns_blocking_mode_null_ip_desc": "Responds with 0.0.0.0 for A and :: for AAAA",
|
||||
"dns_blocking_mode_nxdomain": "NXDOMAIN",
|
||||
"dns_blocking_mode_nxdomain_desc": "Responds with NXDOMAIN code",
|
||||
"dns_blocking_mode_refused": "REFUSED",
|
||||
"dns_blocking_mode_refused_desc": "Responds with REFUSED code",
|
||||
"dns_blocking_mode_title": "Blocking mode",
|
||||
"dns_blocking_mode_ttl_label": "Blocked response TTL, in seconds",
|
||||
"dns_blocking_mode_ttl_placeholder": "Enter TTL",
|
||||
"dns_bootstrap_dns_desc": "Sets the DNS servers used to resolve DoH/DoT upstream resolver hostnames",
|
||||
"dns_bootstrap_dns_desc_2": "Enter IP addresses only. Comments are not allowed",
|
||||
"dns_bootstrap_dns_label": "Bootstrap DNS servers, one per line",
|
||||
"dns_bootstrap_dns_placeholder": "IP addresses",
|
||||
"dns_bootstrap_dns_title": "Bootstrap DNS servers",
|
||||
"dns_bootstrap_servers": "Bootstrap DNS servers",
|
||||
"dns_cache_desc": "Stores DNS responses locally",
|
||||
"dns_cache_size": "Cache size",
|
||||
"dns_cache_size_desc": "Sets the DNS cache size",
|
||||
"dns_cache_size_label": "DNS cache size, in bytes",
|
||||
"dns_cache_size_title": "Cache size",
|
||||
"dns_cache_title": "DNS cache",
|
||||
"dns_clear_cache": "Clear cache",
|
||||
"dns_clear_cache_cancel": "Cancel",
|
||||
"dns_clear_cache_confirm": "Yes, clear",
|
||||
"dns_clear_cache_desc": "This will clear your device's DNS cache and prompt it to request new information from the DNS server",
|
||||
"dns_clear_cache_title": "Clear cache?",
|
||||
"dns_confirm_clear_cache": "Clear DNS cache?",
|
||||
"dns_confirm_clear_cache_desc": "Are you sure you want to clear the DNS cache?",
|
||||
"dns_clear_cache_yes": "Yes, clear",
|
||||
"dns_disallowed_clients": "Disallowed clients",
|
||||
"dns_disallowed_clients_desc": "Drops requests from clients on this list. This setting is ignored when there are entries in Allowed clients",
|
||||
"dns_disallowed_clients_label": "Disallowed clients, one per line",
|
||||
"dns_disallowed_clients_title": "Disallowed clients",
|
||||
"dns_disallowed_domains": "Disallowed domains",
|
||||
"dns_disallowed_domains_desc": "Drops DNS queries that match these domains, and they don’t appear in Query log",
|
||||
"dns_disallowed_domains_desc_2": "Enter exact domain names, wildcards, or URL filter rules, such as example.org, *.example.org, or ||example.org^",
|
||||
"dns_disallowed_domains_label": "Disallowed domains, one per line",
|
||||
"dns_disallowed_domains_title": "Disallowed domains",
|
||||
"dns_dnssec": "DNSSEC",
|
||||
"dns_dnssec_desc": "Adds the DNSSEC flag in outbound DNS queries and checks the result. Requires a DNSSEC-enabled resolver",
|
||||
"dns_edns_client_subnet": "EDNS client subnet",
|
||||
"dns_edns_client_subnet_desc": "Adds EDNS Client Subnet (ECS) data to upstream requests and records ECS values in Query log",
|
||||
"dns_edns_custom_label": "Custom IP address for EDNS",
|
||||
"dns_edns_custom_placeholder": "Enter IP address",
|
||||
"dns_edns_desc": "Add EDNS Client Subnet (ECS) data to upstream requests and record ECS values in Query log. You can use the default client subnet or specify a custom IP address for ECS",
|
||||
"dns_edns_option_custom": "Use custom IP address",
|
||||
"dns_edns_option_default": "Use default EDNS client subnet",
|
||||
"dns_edns_title": "EDNS client subnet",
|
||||
"dns_fallback_dns_desc": "Sets fallback DNS servers to use when upstream DNS servers are unavailable",
|
||||
"dns_fallback_dns_desc_2": "Use the same syntax as for the <a>upstream DNS servers</a>",
|
||||
"dns_fallback_dns_label": "Fallback DNS servers, one per line",
|
||||
"dns_fallback_dns_placeholder": "IP addresses, sdns://, quic://, https://, h3://, or tls://",
|
||||
"dns_fallback_dns_title": "Fallback DNS servers",
|
||||
"dns_fallback_servers": "Fallback DNS servers",
|
||||
"dns_ipv6_resolution": "IPv6 resolution",
|
||||
"dns_ipv6_resolution_desc": "Enables resolution of DNS queries for IPv6 addresses (AAAA records) and adds IPv6 hints to HTTPS responses",
|
||||
"dns_optimistic_caching": "Optimistic caching",
|
||||
"dns_optimistic_caching_desc": "Responds from the cache even if the entries have expired and tries to update them",
|
||||
"dns_override_max_ttl": "Override maximum TTL",
|
||||
"dns_override_max_ttl_desc": "Sets the maximum time DNS responses are cached. Higher TTL values are reduced to this value",
|
||||
"dns_override_max_ttl_label": "Maximum time-to-live (TTL), in seconds",
|
||||
"dns_override_max_ttl_placeholder": "Time-to-live (TTL)",
|
||||
"dns_override_max_ttl_title": "Override maximum TTL",
|
||||
"dns_override_min_ttl": "Override minimum TTL",
|
||||
"dns_override_min_ttl_desc": "Sets the minimum time DNS responses are cached. Lower TTL values are increased to this value",
|
||||
"dns_override_min_ttl_label": "Minimum time-to-live (TTL), in seconds",
|
||||
"dns_override_min_ttl_placeholder": "Time-to-live (TTL)",
|
||||
"dns_override_min_ttl_title": "Override minimum TTL",
|
||||
"dns_private_reverse_page_desc": "Resolves PTR, SOA, and NS requests for ARPA domains with private IP addresses through private upstream servers, DHCP, and /etc/hosts. When disabled, responds to all such requests with NXDOMAIN",
|
||||
"dns_private_reverse_resolvers": "Private reverse DNS resolvers",
|
||||
"dns_private_reverse_resolvers_desc": "Resolves PTR, SOA, and NS requests for ARPA domains with private IP addresses through private upstream servers, DHCP, and /etc/hosts",
|
||||
"dns_private_reverse_resolvers_disabled_desc": "When disabled, responds to all such requests with NXDOMAIN",
|
||||
"dns_private_reverse_resolve_clients_desc": "Resolves client IP addresses to their hostnames using PTR queries. Uses private DNS servers for local clients and upstream servers for clients with public IP addresses",
|
||||
"dns_private_reverse_resolve_clients_title": "Reverse lookup of client IP addresses",
|
||||
"dns_private_reverse_servers_desc": "Sets the DNS servers for private PTR, SOA, and NS requests. These requests come from clients with private IP addresses and ask for ARPA domains containing subnets from private IP address ranges",
|
||||
"dns_private_reverse_servers_desc_2": "If this field is empty, your operating system's default DNS resolvers are used, except for AdGuard Home IP addresses. By default, AdGuard Home uses the following reverse DNS resolvers: %value_1% and %value_2%",
|
||||
"dns_private_reverse_servers_label": "Private reverse DNS servers, one per line",
|
||||
"dns_private_reverse_servers_placeholder": "IP addresses, sdns://, quic://, https://, h3://, or tls://",
|
||||
"dns_private_reverse_servers_title": "Private reverse DNS servers",
|
||||
"dns_rate_limit": "Rate limit",
|
||||
"dns_rate_limit_allowlist": "Rate-limiting allowlist",
|
||||
"dns_rate_limit_allowlist_desc": "Specifies IP addresses excluded from rate limiting",
|
||||
"dns_rate_limit_allowlist_label": "IP addresses, one per line",
|
||||
"dns_rate_limit_allowlist_placeholder": "IP addresses",
|
||||
"dns_rate_limit_allowlist_title": "Rate-limiting allowlist",
|
||||
"dns_rate_limit_desc": "Sets the number of requests per second allowed per client",
|
||||
"dns_rate_limit_desc_2": "A value of 0 means no limit",
|
||||
"dns_rate_limit_no_limit": "No limit",
|
||||
"dns_rate_limit_placeholder": "Enter rate limit",
|
||||
"dns_rate_limit_title": "Rate limit",
|
||||
"dns_rate_limit_value": "%value% req/s",
|
||||
"dns_server_addresses": "Server addresses",
|
||||
"dns_server_addresses_configured_in_file": "Server addresses are configured in the file <strong>%path%</strong>",
|
||||
"dns_server_addresses_desc": "Sets DNS server addresses through which AdGuard Home will be accessible",
|
||||
"dns_server_addresses_desc_2": "View tips for <a>configuring upstream DNS servers</a> and our <b>list of trusted DNS providers</b>",
|
||||
"dns_server_addresses_label": "Server addresses, one per line",
|
||||
"dns_server_addresses_placeholder": "IP addresses, sdns://, quic://, https://, h3://, or tls://",
|
||||
"dns_server_addresses_title": "Server addresses",
|
||||
"dns_server_config_title": "DNS server configuration",
|
||||
"dns_subnet_prefix": "Subnet prefix length for %value% addresses",
|
||||
"dns_subnet_prefix_desc": "Sets the subnet prefix length for %value% addresses used for rate limiting",
|
||||
"dns_subnet_prefix_title": "Subnet prefix length for %value% addresses",
|
||||
"dns_subnet_placeholder": "Enter prefix length",
|
||||
"dns_test_upstreams": "Test upstreams",
|
||||
"dns_upstream_mode": "Upstream mode",
|
||||
"dns_upstream_mode_desc": "Determines how to route your network’s DNS queries to multiple upstream servers",
|
||||
"dns_upstream_mode_title": "Upstream mode",
|
||||
"dns_upstream_servers_title": "Upstream DNS servers",
|
||||
"dns_upstream_timeout": "Upstream timeout",
|
||||
"dns_upstream_timeout_desc": "Sets how long to wait for a response from upstream servers",
|
||||
"dns_upstream_timeout_placeholder": "Enter timeout in seconds",
|
||||
"dns_upstream_timeout_title": "Upstream timeout",
|
||||
"dns_ttl_value": "%value% s",
|
||||
"dns_upstream_validation_invalid": "Invalid format",
|
||||
"dns_upstream_validation_invalid_lines": "Invalid format on lines {lines}",
|
||||
"dns_settings": "DNS",
|
||||
"upstream_dns": "Upstream DNS servers",
|
||||
"upstream_dns_servers_title": "Upstream DNS servers",
|
||||
"upstream_dns_placeholder": "IP addresses, sdns://, quic://, https://, h3://, or tls://",
|
||||
"upstream_dns_addresses": "Server addresses, one per line",
|
||||
"upstream_dns_addresses_faq": "<a>Learn more</a> about configuring upstream DNS servers. Here is a <b>list of known DNS providers</b> to choose from.",
|
||||
"upstream_dns_load_balancing": "Load-balancing",
|
||||
"upstream_dns_load_balancing_desc": "Query one upstream server at a time AdGuard Home uses a weighted random algorithm to select servers with the lowest number of failed lookups and the lowest average lookup time",
|
||||
"upstream_dns_load_balancing": "Load balancing",
|
||||
"upstream_dns_load_balancing_desc": "<p>Query one upstream server at a time</p><p>AdGuard Home uses a weighted random algorithm to select servers with the lowest number of failed lookups and the lowest average lookup time</p>",
|
||||
"upstream_dns_parallel_requests": "Parallel requests",
|
||||
"upstream_dns_parallel_requests_desc": "Use parallel queries to speed up resolving by querying all upstream servers simultaneously",
|
||||
"upstream_dns_parallel_requests_desc": "Use parallel queries to speed up resolving by querying all upstream servers simultaneously",
|
||||
"upstream_dns_fastest_addr": "Fastest IP address",
|
||||
"upstream_dns_fastest_addr_desc": "Wait for responses from <strong>all</strong> DNS servers, measure the TCP connection speed for each server, and return the IP address of the server with the fastest connection speed",
|
||||
"upstream_dns_fastest_addr_warning": "This mode can significantly slow down DNS queries, if one or more upstream servers are not responding. Make sure that your upstream servers are stable and your upstream timeout is low",
|
||||
"upstream_dns_fastest_addr_desc": "Wait for responses from all DNS servers, measure the TCP connection speed for each server, and return the IP address of the server with the fastest connection speed",
|
||||
"upstream_dns_fastest_addr_warning": "This mode can significantly slow down DNS queries, if one or more upstream servers are not responding. Make sure that your upstream servers are stable and your upstream timeout is low",
|
||||
"upstream_examples_title": "Examples",
|
||||
"upstream_example_udp": "<code>%ipv4%</code>,<code>%ipv6%</code>: regular DNS (over UDP)",
|
||||
"upstream_example_udp_port": "<code>%ipv4%</code>,<code>%ipv6%</code>: regular DNS (over UDP, with port)",
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
validatePasswordLength,
|
||||
validateIpNotDuplicate,
|
||||
validateIpPerLine,
|
||||
validateMaxValue,
|
||||
validateBetween,
|
||||
validateMinValue,
|
||||
validateCacheSize,
|
||||
@@ -109,17 +108,52 @@ describe('validateUpstreams', () => {
|
||||
|
||||
it('returns error for a line without dot or colon', () => {
|
||||
const result = validateUpstreams('not-a-valid-upstream');
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).toBe('Invalid format');
|
||||
});
|
||||
|
||||
it('returns error on the correct line number for mixed content', () => {
|
||||
const result = validateUpstreams('1.1.1.1\nbadline\ntls://ok.com');
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).toBe('Invalid format on line 2');
|
||||
});
|
||||
|
||||
it('skips comments and only flags real lines', () => {
|
||||
const result = validateUpstreams('# comment\nbadline\n1.1.1.1');
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).toBe('Invalid format on line 2');
|
||||
});
|
||||
|
||||
it('returns "Invalid format" for single invalid line with trailing newline', () => {
|
||||
const result = validateUpstreams('badline\n');
|
||||
expect(result).toBe('Invalid format');
|
||||
});
|
||||
|
||||
it('returns "Invalid format" for single invalid line with leading newline', () => {
|
||||
const result = validateUpstreams('\nbadline');
|
||||
expect(result).toBe('Invalid format');
|
||||
});
|
||||
|
||||
it('returns "Invalid format on lines 1, 2" when both invalid', () => {
|
||||
const result = validateUpstreams('bad1\nbad2');
|
||||
expect(result).toBe('Invalid format on lines 1, 2');
|
||||
});
|
||||
|
||||
it('returns "Invalid format on line 2" when second line invalid in multi-content', () => {
|
||||
const result = validateUpstreams('1.1.1.1\nbad');
|
||||
expect(result).toBe('Invalid format on line 2');
|
||||
});
|
||||
|
||||
it('handles blank line between two invalid lines', () => {
|
||||
const result = validateUpstreams('bad1\n\nbad2');
|
||||
expect(result).toBe('Invalid format on lines 1, 3');
|
||||
});
|
||||
|
||||
it('returns "Invalid format" for comment-then-invalid (one content line)', () => {
|
||||
const result = validateUpstreams('# comment\nbadline');
|
||||
expect(result).toBe('Invalid format');
|
||||
});
|
||||
|
||||
it('returns "Invalid format" for invalid-then-comment (one content line)', () => {
|
||||
const result = validateUpstreams('badline\n# comment');
|
||||
expect(result).toBe('Invalid format');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -376,12 +410,6 @@ describe('validateIpPerLine', () => {
|
||||
});
|
||||
|
||||
describe('numeric range validators', () => {
|
||||
it('validateMaxValue returns error when value exceeds max', () => {
|
||||
expect(validateMaxValue(5, 4)).toBeTruthy();
|
||||
expect(validateMaxValue(4, 4)).toBeUndefined();
|
||||
expect(validateMaxValue(0, 4)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('validateBetween returns error when value is out of range', () => {
|
||||
expect(validateBetween(-1, 0, 32)).toBeTruthy();
|
||||
expect(validateBetween(33, 0, 32)).toBeTruthy();
|
||||
|
||||
@@ -2,15 +2,26 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getDnsConfig: vi.fn(),
|
||||
setDnsConfig: vi.fn(),
|
||||
addErrorToast: vi.fn(),
|
||||
addSuccessToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('panel/api/Api', () => ({
|
||||
apiClient: { getDnsConfig: mocks.getDnsConfig },
|
||||
apiClient: {
|
||||
getDnsConfig: mocks.getDnsConfig,
|
||||
setDnsConfig: mocks.setDnsConfig,
|
||||
},
|
||||
}));
|
||||
vi.mock('panel/stores/toasts', () => ({
|
||||
addErrorToast: mocks.addErrorToast,
|
||||
addSuccessToast: mocks.addSuccessToast,
|
||||
}));
|
||||
vi.mock('panel/common/intl', () => ({
|
||||
default: { getMessage: vi.fn((key: string) => key) },
|
||||
}));
|
||||
vi.mock('panel/stores/toasts', () => ({ addErrorToast: mocks.addErrorToast }));
|
||||
|
||||
import { getDnsConfig, dnsConfigState } from 'panel/stores/dnsConfig';
|
||||
import { getDnsConfig, dnsConfigState, toggleResolveClients } from 'panel/stores/dnsConfig';
|
||||
|
||||
describe('getDnsConfig', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
@@ -27,3 +38,29 @@ describe('getDnsConfig', () => {
|
||||
expect(dnsConfigState.upstream_mode).toBe('load_balance');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleResolveClients', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('toggles resolve_clients and persists (FR-032)', async () => {
|
||||
mocks.setDnsConfig.mockResolvedValue({});
|
||||
|
||||
const before = dnsConfigState.resolve_clients;
|
||||
await toggleResolveClients();
|
||||
expect(dnsConfigState.resolve_clients).toBe(!before);
|
||||
|
||||
// Toggle back
|
||||
await toggleResolveClients();
|
||||
expect(dnsConfigState.resolve_clients).toBe(before);
|
||||
});
|
||||
|
||||
it('calls apiClient.setDnsConfig with inverted value (FR-032)', async () => {
|
||||
mocks.setDnsConfig.mockResolvedValue({});
|
||||
|
||||
const before = dnsConfigState.resolve_clients;
|
||||
await toggleResolveClients();
|
||||
expect(mocks.setDnsConfig).toHaveBeenCalledWith({
|
||||
resolve_clients: !before,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('panel/common/intl', () => ({
|
||||
default: {
|
||||
getMessage: vi.fn((key: string, values?: Record<string, string | number>) => {
|
||||
if (key === 'form_error_format_line') {
|
||||
return `Invalid format on line ${values?.line}`;
|
||||
}
|
||||
if (key === 'form_error_format_lines') {
|
||||
return `Invalid format on lines ${values?.lines}`;
|
||||
}
|
||||
if (key === 'form_error_format') {
|
||||
return 'Invalid format';
|
||||
}
|
||||
return key;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { validateClientsPerLine } from 'panel/helpers/validators';
|
||||
|
||||
describe('validateClientsPerLine', () => {
|
||||
it('returns undefined for empty string', () => {
|
||||
expect(validateClientsPerLine('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for valid IPv4', () => {
|
||||
expect(validateClientsPerLine('192.168.1.1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for valid IPv6', () => {
|
||||
expect(validateClientsPerLine('::1')).toBeUndefined();
|
||||
expect(validateClientsPerLine('2001:db8::1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for valid IPv4 CIDR', () => {
|
||||
expect(validateClientsPerLine('192.168.1.0/24')).toBeUndefined();
|
||||
expect(validateClientsPerLine('10.0.0.0/8')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for valid IPv6 CIDR', () => {
|
||||
expect(validateClientsPerLine('2001:db8::/32')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for valid ClientID', () => {
|
||||
expect(validateClientsPerLine('my-client-id')).toBeUndefined();
|
||||
expect(validateClientsPerLine('client-123')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for mixed valid lines', () => {
|
||||
expect(validateClientsPerLine('192.168.1.1\n10.0.0.0/8\nmy-client-id')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns "Invalid format" for single invalid line', () => {
|
||||
expect(validateClientsPerLine('BAD ENTRY!')).toBe('Invalid format');
|
||||
});
|
||||
|
||||
it('returns "Invalid format on line N" when specific line is invalid', () => {
|
||||
expect(validateClientsPerLine('192.168.1.1\nBAD!')).toBe('Invalid format on line 2');
|
||||
});
|
||||
|
||||
it('returns "Invalid format on lines N, M" when multiple lines invalid', () => {
|
||||
expect(validateClientsPerLine('BAD1!\n192.168.1.1\nBAD2!')).toBe(
|
||||
'Invalid format on lines 1, 3',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects MAC address', () => {
|
||||
expect(validateClientsPerLine('aa:bb:cc:dd:ee:ff')).toBe('Invalid format');
|
||||
});
|
||||
|
||||
it('rejects CIDR with bad prefix', () => {
|
||||
expect(validateClientsPerLine('192.168.1.0/33')).toBe('Invalid format');
|
||||
});
|
||||
|
||||
it('returns "Invalid format" for single invalid line with trailing newline', () => {
|
||||
expect(validateClientsPerLine('BAD ENTRY!\n')).toBe('Invalid format');
|
||||
});
|
||||
|
||||
it('returns "Invalid format" for single invalid line with leading newline', () => {
|
||||
expect(validateClientsPerLine('\nBAD ENTRY!')).toBe('Invalid format');
|
||||
});
|
||||
|
||||
it('returns "Invalid format on lines 1, 2" when both lines invalid', () => {
|
||||
expect(validateClientsPerLine('BAD1!\nBAD2!')).toBe('Invalid format on lines 1, 2');
|
||||
});
|
||||
|
||||
it('returns "Invalid format on line 2" when second line invalid in multi-content input', () => {
|
||||
expect(validateClientsPerLine('192.168.1.1\nBAD!')).toBe('Invalid format on line 2');
|
||||
});
|
||||
|
||||
it('returns undefined for all-blank input', () => {
|
||||
expect(validateClientsPerLine('\n\n')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles blank line between two invalid lines', () => {
|
||||
expect(validateClientsPerLine('BAD1!\n\nBAD2!')).toBe('Invalid format on lines 1, 3');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('panel/common/intl', () => ({
|
||||
default: {
|
||||
getMessage: vi.fn((key: string, values?: Record<string, string | number>) => {
|
||||
if (key === 'form_error_format_line') {
|
||||
return `Invalid format on line ${values?.line}`;
|
||||
}
|
||||
if (key === 'form_error_format_lines') {
|
||||
return `Invalid format on lines ${values?.lines}`;
|
||||
}
|
||||
if (key === 'form_error_format') {
|
||||
return 'Invalid format';
|
||||
}
|
||||
return key;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { validateDomainsPerLine } from 'panel/helpers/validators';
|
||||
|
||||
describe('validateDomainsPerLine', () => {
|
||||
it('returns undefined for empty string', () => {
|
||||
expect(validateDomainsPerLine('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for plain domain', () => {
|
||||
expect(validateDomainsPerLine('example.org')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for wildcard domain', () => {
|
||||
expect(validateDomainsPerLine('*.example.org')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for AdGuard URL filter rule', () => {
|
||||
expect(validateDomainsPerLine('||example.org^')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for regex pattern', () => {
|
||||
expect(validateDomainsPerLine('/regex.pattern/')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for comment line', () => {
|
||||
expect(validateDomainsPerLine('# this is a comment')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for mixed valid lines with comments', () => {
|
||||
expect(
|
||||
validateDomainsPerLine('# comment\nexample.org\n||ads.example.org^'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns "Invalid format" for entry without dot', () => {
|
||||
expect(validateDomainsPerLine('notadomain')).toBe('Invalid format');
|
||||
});
|
||||
|
||||
it('returns "Invalid format on line N" when specific line has no dot', () => {
|
||||
expect(validateDomainsPerLine('example.org\nnodot')).toBe('Invalid format on line 2');
|
||||
});
|
||||
|
||||
it('returns "Invalid format on lines N, M" when multiple lines invalid', () => {
|
||||
expect(validateDomainsPerLine('nodot1\nexample.org\nnodot2')).toBe(
|
||||
'Invalid format on lines 1, 3',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns "Invalid format" for single invalid line with trailing newline', () => {
|
||||
expect(validateDomainsPerLine('notadomain\n')).toBe('Invalid format');
|
||||
});
|
||||
|
||||
it('returns "Invalid format" for single invalid line with leading newline', () => {
|
||||
expect(validateDomainsPerLine('\nnotadomain')).toBe('Invalid format');
|
||||
});
|
||||
|
||||
it('returns "Invalid format on lines 1, 2" when both lines invalid', () => {
|
||||
expect(validateDomainsPerLine('nodot1\nnodot2')).toBe('Invalid format on lines 1, 2');
|
||||
});
|
||||
|
||||
it('returns "Invalid format on line 2" when second line invalid in multi-content input', () => {
|
||||
expect(validateDomainsPerLine('example.org\nnodot')).toBe('Invalid format on line 2');
|
||||
});
|
||||
|
||||
it('returns undefined for all-blank input', () => {
|
||||
expect(validateDomainsPerLine('\n\n')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles blank line between two invalid lines', () => {
|
||||
expect(validateDomainsPerLine('nodot1\n\nnodot2')).toBe('Invalid format on lines 1, 3');
|
||||
});
|
||||
|
||||
it('returns "Invalid format" for comment-then-invalid (one content line)', () => {
|
||||
expect(validateDomainsPerLine('# comment\nnotadomain')).toBe('Invalid format');
|
||||
});
|
||||
|
||||
it('returns "Invalid format" for invalid-then-comment (one content line)', () => {
|
||||
expect(validateDomainsPerLine('notadomain\n# comment')).toBe('Invalid format');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('panel/common/intl', () => ({
|
||||
default: {
|
||||
getMessage: vi.fn((key: string, values?: Record<string, string | number>) => {
|
||||
if (key === 'form_error_format_line') {
|
||||
return `Invalid format on line ${values?.line}`;
|
||||
}
|
||||
if (key === 'form_error_format_lines') {
|
||||
return `Invalid format on lines ${values?.lines}`;
|
||||
}
|
||||
if (key === 'form_error_format') {
|
||||
return 'Invalid format';
|
||||
}
|
||||
return key;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { validateIpPerLine } from 'panel/helpers/validators';
|
||||
|
||||
describe('validateIpPerLine', () => {
|
||||
it('returns undefined for empty string', () => {
|
||||
expect(validateIpPerLine('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for valid IPs', () => {
|
||||
expect(validateIpPerLine('192.168.1.1')).toBeUndefined();
|
||||
expect(validateIpPerLine('192.168.1.1\n10.0.0.1')).toBeUndefined();
|
||||
expect(validateIpPerLine('::1\n192.168.1.1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns "Invalid format" for single invalid line', () => {
|
||||
expect(validateIpPerLine('not-an-ip')).toBe('Invalid format');
|
||||
});
|
||||
|
||||
it('returns "Invalid format on line 2" when second line is invalid', () => {
|
||||
expect(validateIpPerLine('192.168.1.1\nbad-ip')).toBe('Invalid format on line 2');
|
||||
});
|
||||
|
||||
it('returns "Invalid format on lines 1, 3" when multiple lines invalid', () => {
|
||||
expect(validateIpPerLine('bad1\n192.168.1.1\nbad2')).toBe('Invalid format on lines 1, 3');
|
||||
});
|
||||
|
||||
it('returns "Invalid format" for single invalid line with trailing newline', () => {
|
||||
expect(validateIpPerLine('bad\n')).toBe('Invalid format');
|
||||
});
|
||||
|
||||
it('returns "Invalid format" for single invalid line with leading newline', () => {
|
||||
expect(validateIpPerLine('\nbad')).toBe('Invalid format');
|
||||
});
|
||||
|
||||
it('returns "Invalid format on lines 1, 2" when both lines invalid', () => {
|
||||
expect(validateIpPerLine('bad\nbad2')).toBe('Invalid format on lines 1, 2');
|
||||
});
|
||||
|
||||
it('returns "Invalid format on line 2" when second line invalid in multi-content input', () => {
|
||||
expect(validateIpPerLine('192.168.1.1\nbad')).toBe('Invalid format on line 2');
|
||||
});
|
||||
|
||||
it('returns undefined for all-blank input', () => {
|
||||
expect(validateIpPerLine('\n\n')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles blank line between two invalid lines', () => {
|
||||
expect(validateIpPerLine('bad1\n\nbad2')).toBe('Invalid format on lines 1, 3');
|
||||
});
|
||||
});
|
||||
@@ -3,3 +3,19 @@ import { afterEach } from 'vitest';
|
||||
import { cleanup } from '@solidjs/testing-library';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
// Mock window.matchMedia for components that use useIsMobile
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: (query: string): MediaQueryList =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}) as MediaQueryList,
|
||||
});
|
||||
|
||||
@@ -22,6 +22,33 @@
|
||||
&.end {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
&.modal {
|
||||
align-items: flex-start;
|
||||
|
||||
.text {
|
||||
max-width: 100%;
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: var(--fs-text-t3);
|
||||
line-height: var(--lh-t3-normal);
|
||||
font-weight: var(--weight-regular);
|
||||
|
||||
p {
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: 16px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
@@ -31,6 +58,10 @@
|
||||
.text {
|
||||
max-width: 250px;
|
||||
flex: 1;
|
||||
|
||||
&.fullWidth {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
|
||||
@@ -16,6 +16,7 @@ type Props<T = string | number | boolean> = {
|
||||
textClass?: string;
|
||||
verticalAlign?: 'center' | 'start' | 'end';
|
||||
ref?: HTMLDivElement | ((el: HTMLDivElement) => void);
|
||||
inModal?: boolean;
|
||||
};
|
||||
|
||||
export const Radio = <T extends number | string | boolean = string>(props: Props<T>) => {
|
||||
@@ -35,6 +36,9 @@ export const Radio = <T extends number | string | boolean = string>(props: Props
|
||||
s.radio,
|
||||
props.class,
|
||||
props.verticalAlign && s[props.verticalAlign],
|
||||
{
|
||||
[s.modal]: props.inModal,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<input
|
||||
@@ -52,7 +56,7 @@ export const Radio = <T extends number | string | boolean = string>(props: Props
|
||||
class={cn(s.icon, { [s.active]: props.value === o.value })}
|
||||
/>
|
||||
</div>
|
||||
<div class={cn(s.text, props.textClass)}>
|
||||
<div class={s.text}>
|
||||
<div>{o.text}</div>
|
||||
<Show when={o.description}>
|
||||
<div class={cn(theme.text.t4, s.description)}>{o.description}</div>
|
||||
|
||||
@@ -38,6 +38,7 @@ export const Textarea = (props: Props) => {
|
||||
cols={props.cols}
|
||||
rows={props.rows}
|
||||
onChange={(e) => (props.onChange as any)?.(e)}
|
||||
onBlur={(e) => (props.onBlur as any)?.(e)}
|
||||
wrap={props.wrap}
|
||||
maxLength={props.maxLength}
|
||||
disabled={props.disabled}
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
text-align: left;
|
||||
background-color: transparent;
|
||||
outline: none;
|
||||
|
||||
&.compact {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
|
||||
@@ -10,6 +10,7 @@ type Props = {
|
||||
children: JSX.Element;
|
||||
defaultOpen?: boolean;
|
||||
class?: string;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export const Accordion = (props: Props) => {
|
||||
@@ -23,7 +24,7 @@ export const Accordion = (props: Props) => {
|
||||
<div class={cn(s.accordion, props.class)}>
|
||||
<button
|
||||
type="button"
|
||||
class={s.header}
|
||||
class={cn(s.header, { [s.compact]: props.compact })}
|
||||
onClick={toggleOpen}
|
||||
aria-expanded={isOpen()}
|
||||
aria-controls="accordion-content"
|
||||
|
||||
@@ -5,14 +5,9 @@
|
||||
}
|
||||
|
||||
.body {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
border: none;
|
||||
padding: 0 16px;
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
max-height: 70vh;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
@@ -29,5 +24,12 @@
|
||||
|
||||
.description {
|
||||
padding: 8px 16px;
|
||||
color: var(--default-description-text);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p + p {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type JSX } from 'solid-js';
|
||||
import { type JSX, Show } from 'solid-js';
|
||||
import cn from 'clsx';
|
||||
|
||||
import { Dialog } from 'panel/common/ui/Dialog';
|
||||
@@ -17,7 +17,9 @@ type Props = {
|
||||
class?: string;
|
||||
children?: JSX.Element;
|
||||
footer?: JSX.Element;
|
||||
description?: string;
|
||||
description?: string | JSX.Element;
|
||||
hideSubmit?: boolean;
|
||||
buttonText?: string;
|
||||
};
|
||||
|
||||
export const ConfigDialog = (props: Props) => {
|
||||
@@ -37,15 +39,17 @@ export const ConfigDialog = (props: Props) => {
|
||||
</fieldset>
|
||||
<div class={s.footer}>
|
||||
{props.footer}
|
||||
<Button
|
||||
variant="primary"
|
||||
class={s.saveButton}
|
||||
disabled={isDisabled()}
|
||||
data-testid="config-dialog-save"
|
||||
onClick={props.onSubmit}
|
||||
>
|
||||
{intl.getMessage('save')}
|
||||
</Button>
|
||||
<Show when={!props.hideSubmit}>
|
||||
<Button
|
||||
variant="primary"
|
||||
class={s.saveButton}
|
||||
disabled={isDisabled()}
|
||||
data-testid="config-dialog-save"
|
||||
onClick={props.onSubmit}
|
||||
>
|
||||
{props.buttonText || intl.getMessage('save')}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
max-width: 720px;
|
||||
max-height: calc(100vh - 32px);
|
||||
background-color: var(--default-page-background);
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
@@ -49,6 +50,7 @@
|
||||
padding: 8px 0;
|
||||
color: var(--default-main-text);
|
||||
text-align: left;
|
||||
overflow: auto;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
border-radius: 16px;
|
||||
@@ -68,6 +70,7 @@
|
||||
.dialog-header {
|
||||
margin: 0;
|
||||
padding: 8px 48px 8px 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dialog-close-button {
|
||||
|
||||
@@ -76,9 +76,9 @@ export const Dropdown = (props: Props) => {
|
||||
// to plain `bottom`/`top` (ignoring left/right), so mapping `bottomRight` ->
|
||||
// `bottom-end` is an intentional behavior change that honors the `position`
|
||||
// prop. `flip` keeps the popover on-screen near viewport edges.
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
const placement = () => {
|
||||
switch (props.position) {
|
||||
const position = untrack(() => props.position);
|
||||
switch (position) {
|
||||
case 'topRight':
|
||||
return 'top-end';
|
||||
case 'topLeft':
|
||||
|
||||
@@ -116,50 +116,54 @@ export const Footer = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dropdown
|
||||
trigger="click"
|
||||
open={themeDropdownOpen()}
|
||||
onOpenChange={setThemeDropdownOpen}
|
||||
menu={
|
||||
<div class={theme.dropdown.menu}>
|
||||
<For each={Object.values(THEMES)}>
|
||||
{(v) => (
|
||||
<button
|
||||
type="button"
|
||||
class={cn(theme.dropdown.item, {
|
||||
[theme.dropdown.item_active]: currentTheme() === v,
|
||||
})}
|
||||
onClick={() => onThemeChange(v)}
|
||||
>
|
||||
{themeTranslations()[v]}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<div class={s.dropdownWrapper}>
|
||||
<Dropdown
|
||||
trigger="click"
|
||||
open={themeDropdownOpen()}
|
||||
onOpenChange={setThemeDropdownOpen}
|
||||
menu={
|
||||
<div class={theme.dropdown.menu}>
|
||||
<For each={Object.values(THEMES)}>
|
||||
{(v) => (
|
||||
<button
|
||||
type="button"
|
||||
class={cn(theme.dropdown.item, {
|
||||
[theme.dropdown.item_active]: currentTheme() === v,
|
||||
})}
|
||||
onClick={() => onThemeChange(v)}
|
||||
>
|
||||
{themeTranslations()[v]}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
}
|
||||
class={s.dropdown}
|
||||
position="bottomRight"
|
||||
>
|
||||
<div class={s.dropdownTrigger}>
|
||||
<Icon icon={getThemeIcon()} class={s.icon} />
|
||||
<span>
|
||||
{
|
||||
themeTranslations()[
|
||||
isLoggedIn() ? currentTheme() : currentThemeLocal()
|
||||
]
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
class={s.dropdown}
|
||||
position="bottomRight"
|
||||
>
|
||||
<div class={s.dropdownTrigger}>
|
||||
<Icon icon={getThemeIcon() as any} class={s.icon} />
|
||||
<span>
|
||||
{
|
||||
themeTranslations()[
|
||||
isLoggedIn() ? currentTheme() : currentThemeLocal()
|
||||
]
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<LanguageDropdown
|
||||
value={currentLanguage()}
|
||||
languages={LANGUAGES}
|
||||
languageNames={LANGUAGE_NAMES}
|
||||
onChange={(lang: string) => changeLanguage(lang as LocalesType)}
|
||||
class={s.dropdown}
|
||||
position="bottomRight"
|
||||
/>
|
||||
<div class={s.dropdownWrapper}>
|
||||
<LanguageDropdown
|
||||
value={currentLanguage()}
|
||||
languages={LANGUAGES}
|
||||
languageNames={LANGUAGE_NAMES}
|
||||
onChange={(lang: string) => changeLanguage(lang as LocalesType)}
|
||||
class={s.dropdown}
|
||||
position="bottomRight"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -116,6 +116,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.dropdownWrapper {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -39,10 +39,8 @@ export const LanguageDropdown = (props: LanguageDropdownProps) => {
|
||||
if (!props.sort) {
|
||||
return keys;
|
||||
}
|
||||
// eslint-disable-next-line solid/reactivity -- sort callback inside createMemo; reads already tracked
|
||||
return keys.sort((a, b) =>
|
||||
(props.languages[a] || '').localeCompare(props.languages[b] || ''),
|
||||
);
|
||||
const languages = props.languages;
|
||||
return keys.sort((a, b) => (languages[a] || '').localeCompare(languages[b] || ''));
|
||||
});
|
||||
|
||||
const currentLabel = () => getLanguageShortLabel(props.value);
|
||||
|
||||
@@ -66,6 +66,14 @@
|
||||
|
||||
.desc {
|
||||
color: var(--default-description-text);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p + p {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.descDisabled {
|
||||
@@ -93,6 +101,10 @@
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
align-self: stretch;
|
||||
|
||||
&.largeTitle {
|
||||
padding-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
|
||||
@@ -12,18 +12,20 @@ type SettingRowVariant = 'switch' | 'link' | 'switch-link';
|
||||
type Props = {
|
||||
id: string;
|
||||
title: string;
|
||||
titleClass?: string;
|
||||
description?: string | JSX.Element;
|
||||
descriptionClass?: string;
|
||||
value?: string;
|
||||
variant: SettingRowVariant;
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
titleClass?: string;
|
||||
onChange?: (checked: boolean) => void;
|
||||
onClick?: () => void;
|
||||
class?: string;
|
||||
children?: JSX.Element;
|
||||
divider?: boolean;
|
||||
align?: 'top' | 'center';
|
||||
largeTitle?: boolean;
|
||||
};
|
||||
|
||||
export const SettingRow = (props: Props) => {
|
||||
@@ -119,7 +121,9 @@ export const SettingRow = (props: Props) => {
|
||||
</div>
|
||||
<Show when={props.description}>
|
||||
<div
|
||||
class={cn(s.desc, theme.text.t3, { [s.descDisabled]: props.disabled })}
|
||||
class={cn(s.desc, props.descriptionClass, theme.text.t3, {
|
||||
[s.descDisabled]: props.disabled,
|
||||
})}
|
||||
>
|
||||
{props.description}
|
||||
</div>
|
||||
@@ -137,7 +141,10 @@ export const SettingRow = (props: Props) => {
|
||||
<Show when={isSwitchLink() && props.divider}>
|
||||
<div class={s.divider} />
|
||||
</Show>
|
||||
<div class={s.input} onClick={handleInputClick}>
|
||||
<div
|
||||
class={cn(s.input, props.largeTitle && s.largeTitle)}
|
||||
onClick={handleInputClick}
|
||||
>
|
||||
<Show when={isSwitchVariant()}>
|
||||
<Switch
|
||||
id={props.id}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type JSX, createMemo, For, Show } from 'solid-js';
|
||||
import { type JSX, createMemo, For, Show, untrack } from 'solid-js';
|
||||
import { createStore } from 'solid-js/store';
|
||||
import cn from 'clsx';
|
||||
|
||||
@@ -56,8 +56,7 @@ export interface TableProps<T = any> {
|
||||
export const Table = <T extends Record<string, any>>(props: TableProps<T>) => {
|
||||
const [state, setState] = createStore({
|
||||
currentPage: 0,
|
||||
// eslint-disable-next-line solid/reactivity -- one-time initial value from props
|
||||
pageSize: props.pageSize ?? DEFAULT_PAGE_SIZE_OPTIONS[0],
|
||||
pageSize: untrack(() => props.pageSize) ?? DEFAULT_PAGE_SIZE_OPTIONS[0],
|
||||
sortKey: props.defaultSort?.key ?? (null as string | null),
|
||||
sortDirection: props.defaultSort?.direction ?? ('asc' as 'asc' | 'desc'),
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import { getDnsStatus, getTimerStatus, dashboardState } from '../../stores/dashb
|
||||
|
||||
import s from './styles.module.pcss';
|
||||
import { DnsSettings } from '../DnsSettings';
|
||||
import { PrivateReverse } from '../DnsSettings/PrivateReverse';
|
||||
import { UserRules } from '../UserRules';
|
||||
import { BlockedServices } from '../BlockedServices';
|
||||
import { Clients } from '../Clients/Clients';
|
||||
@@ -126,6 +127,7 @@ const App = () => {
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/encryption" component={Encryption} />
|
||||
<Route path="/dns" component={DnsSettings} />
|
||||
<Route path="/dns/private-reverse" component={PrivateReverse} />
|
||||
<Route path="/blocklists" component={Blocklists} />
|
||||
<Route path="/allowlists" component={Allowlists} />
|
||||
<Route path="/user_rules" component={UserRules} />
|
||||
|
||||
@@ -14,12 +14,7 @@ import { RoutePath } from 'panel/components/Routes/Paths';
|
||||
import { buildClientBreadcrumbs } from 'panel/helpers/buildClientBreadcrumbs';
|
||||
import { ScheduleRow } from './ScheduleRow';
|
||||
import { ScheduleModal } from './ScheduleModal';
|
||||
import {
|
||||
type DayKey,
|
||||
DAYS_OF_WEEK,
|
||||
type ScheduleData,
|
||||
type ScheduleDayData,
|
||||
} from './helpers';
|
||||
import { type DayKey, DAYS_OF_WEEK, type ScheduleData, type ScheduleDayData } from './helpers';
|
||||
import { getDayName } from './getDayName';
|
||||
import s from './InactivitySchedule.module.pcss';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createMemo, Show } from 'solid-js';
|
||||
import { createMemo, Show, untrack } from 'solid-js';
|
||||
import cn from 'clsx';
|
||||
import {
|
||||
Chart,
|
||||
@@ -109,8 +109,7 @@ export const StatCard = (props: StatCardProps) => {
|
||||
};
|
||||
});
|
||||
|
||||
// eslint-disable-next-line solid/reactivity -- color is stable per card; plugin captures at mount
|
||||
const cursorLinePlugin = createCursorLinePlugin(props.color);
|
||||
const cursorLinePlugin = createCursorLinePlugin(untrack(() => props.color));
|
||||
|
||||
const externalTooltipHandler = createExternalTooltipHandler(
|
||||
() => tooltipEl,
|
||||
@@ -155,8 +154,7 @@ export const StatCard = (props: StatCardProps) => {
|
||||
|
||||
const percent = () => props.percentValue ?? 0;
|
||||
|
||||
// eslint-disable-next-line solid/reactivity -- accessors consumed inside useChart's createEffect/onMount
|
||||
const setCanvasRef = useChart(chartData, chartOptions, [cursorLinePlugin]);
|
||||
const setCanvasRef = untrack(() => useChart(chartData, chartOptions, [cursorLinePlugin]));
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -27,21 +27,19 @@ export const useSortedData = <T extends SortableItem>(
|
||||
const [sortDirection, setSortDirection] = createSignal<SortDirection>(defaultSortDirection);
|
||||
|
||||
const sortedData = createMemo(() => {
|
||||
return (
|
||||
[...data()]
|
||||
// eslint-disable-next-line solid/reactivity -- sort callback inside createMemo; reads already tracked
|
||||
.sort((a, b) => {
|
||||
const modifier = sortDirection() === 'asc' ? 1 : -1;
|
||||
if (sortField() === 'name') {
|
||||
return a.name.localeCompare(b.name) * modifier;
|
||||
}
|
||||
return (a.count - b.count) * modifier;
|
||||
})
|
||||
.slice(0, limit)
|
||||
);
|
||||
const direction = sortDirection();
|
||||
const field = sortField();
|
||||
return [...data()]
|
||||
.sort((a, b) => {
|
||||
const modifier = direction === 'asc' ? 1 : -1;
|
||||
if (field === 'name') {
|
||||
return a.name.localeCompare(b.name) * modifier;
|
||||
}
|
||||
return (a.count - b.count) * modifier;
|
||||
})
|
||||
.slice(0, limit);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line solid/reactivity -- returned to consumers, called from JSX (tracked)
|
||||
const handleSort = (field: SortField) => {
|
||||
untrack(() => {
|
||||
if (sortField() === field) {
|
||||
|
||||
@@ -1,33 +1,79 @@
|
||||
import { createMemo } from 'solid-js';
|
||||
import cn from 'clsx';
|
||||
|
||||
import { accessState, setAccessList } from 'panel/stores/access';
|
||||
import { accessState } from 'panel/stores/access';
|
||||
import intl from 'panel/common/intl';
|
||||
import { SettingRow } from 'panel/common/ui/SettingRow';
|
||||
import { useDialogOpen } from 'panel/hooks/useField';
|
||||
import { getListSummary } from '../helpers';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
import { Form } from './Form';
|
||||
import { AllowedClientsDialog } from './blocks/AllowedClientsDialog';
|
||||
import { DisallowedClientsDialog } from './blocks/DisallowedClientsDialog';
|
||||
import { DisallowedDomainsDialog } from './blocks/DisallowedDomainsDialog';
|
||||
|
||||
export const Access = () => {
|
||||
const handleFormSubmit = (values: any) => {
|
||||
setAccessList(values);
|
||||
};
|
||||
const allowedDialog = useDialogOpen();
|
||||
const disallowedClientsDialog = useDialogOpen();
|
||||
const disallowedDomainsDialog = useDialogOpen();
|
||||
|
||||
const allowedClientsOn = createMemo(() => accessState.allowed_clients.trim().length > 0);
|
||||
const processing = () => accessState.processingSet;
|
||||
|
||||
const allowedClientsValue = createMemo(() => getListSummary(accessState.allowed_clients));
|
||||
const disallowedClientsValue = createMemo(() => getListSummary(accessState.disallowed_clients));
|
||||
const disallowedDomainsValue = createMemo(() => getListSummary(accessState.blocked_hosts));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2
|
||||
class={cn(
|
||||
theme.layout.subtitle,
|
||||
theme.layout.subtitle_compact,
|
||||
theme.title.h5,
|
||||
theme.title.h4_tablet,
|
||||
)}
|
||||
>
|
||||
{intl.getMessage('access_settings_title')}
|
||||
<h2 class={cn(theme.layout.subtitle, theme.title.h5, theme.title.h4_tablet)}>
|
||||
{intl.getMessage('dns_access_settings_title')}
|
||||
</h2>
|
||||
|
||||
<Form
|
||||
initialValues={accessState}
|
||||
onSubmit={handleFormSubmit}
|
||||
processingSet={accessState.processingSet}
|
||||
<SettingRow
|
||||
variant="link"
|
||||
id="allowed_clients"
|
||||
title={intl.getMessage('dns_allowed_clients')}
|
||||
description={intl.getMessage('dns_allowed_clients_desc')}
|
||||
value={allowedClientsValue()}
|
||||
onClick={allowedDialog.openDialog}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
variant="link"
|
||||
id="disallowed_clients"
|
||||
title={intl.getMessage('dns_disallowed_clients')}
|
||||
description={intl.getMessage('dns_disallowed_clients_desc')}
|
||||
value={disallowedClientsValue()}
|
||||
disabled={allowedClientsOn()}
|
||||
onClick={disallowedClientsDialog.openDialog}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
variant="link"
|
||||
id="disallowed_domains"
|
||||
title={intl.getMessage('dns_disallowed_domains')}
|
||||
description={intl.getMessage('dns_disallowed_domains_desc')}
|
||||
value={disallowedDomainsValue()}
|
||||
onClick={disallowedDomainsDialog.openDialog}
|
||||
/>
|
||||
|
||||
<AllowedClientsDialog
|
||||
open={allowedDialog.open}
|
||||
onClose={allowedDialog.closeDialog}
|
||||
processing={processing()}
|
||||
/>
|
||||
|
||||
<DisallowedClientsDialog
|
||||
open={disallowedClientsDialog.open}
|
||||
onClose={disallowedClientsDialog.closeDialog}
|
||||
processing={processing()}
|
||||
/>
|
||||
|
||||
<DisallowedDomainsDialog
|
||||
open={disallowedDomainsDialog.open}
|
||||
onClose={disallowedDomainsDialog.closeDialog}
|
||||
processing={processing()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
import { createSignal, createMemo, For } from 'solid-js';
|
||||
import type { JSX } from 'solid-js';
|
||||
|
||||
import intl from 'panel/common/intl';
|
||||
import { Textarea } from 'panel/common/controls/Textarea';
|
||||
import { Button } from 'panel/common/ui/Button';
|
||||
import { FaqTooltip } from 'panel/common/ui/FaqTooltip';
|
||||
import { CLIENT_ID_LINK } from 'panel/helpers/constants';
|
||||
import { validateIpPerLine } from 'panel/helpers/validators';
|
||||
import { removeEmptyLines, trimMultilineString } from 'panel/helpers/helpers';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
type FormData = {
|
||||
allowed_clients: string;
|
||||
disallowed_clients: string;
|
||||
blocked_hosts: string;
|
||||
};
|
||||
|
||||
type FormProps = {
|
||||
initialValues?: {
|
||||
allowed_clients?: string;
|
||||
disallowed_clients?: string;
|
||||
blocked_hosts?: string;
|
||||
};
|
||||
onSubmit: (data: FormData) => void;
|
||||
processingSet: boolean;
|
||||
};
|
||||
|
||||
export const Form = (props: FormProps) => {
|
||||
const [allowedClients, setAllowedClients] = createSignal(
|
||||
props.initialValues?.allowed_clients || '',
|
||||
);
|
||||
const [disallowedClients, setDisallowedClients] = createSignal(
|
||||
props.initialValues?.disallowed_clients || '',
|
||||
);
|
||||
const [blockedHosts, setBlockedHosts] = createSignal(props.initialValues?.blocked_hosts || '');
|
||||
|
||||
const [allowedClientsError, setAllowedClientsError] = createSignal('');
|
||||
const [disallowedClientsError, setDisallowedClientsError] = createSignal('');
|
||||
|
||||
const getFieldValue = (id: keyof FormData) => {
|
||||
switch (id) {
|
||||
case 'allowed_clients':
|
||||
return allowedClients();
|
||||
case 'disallowed_clients':
|
||||
return disallowedClients();
|
||||
case 'blocked_hosts':
|
||||
return blockedHosts();
|
||||
}
|
||||
};
|
||||
|
||||
const setFieldValue = (id: keyof FormData, value: string) => {
|
||||
switch (id) {
|
||||
case 'allowed_clients':
|
||||
setAllowedClients(value);
|
||||
break;
|
||||
case 'disallowed_clients':
|
||||
setDisallowedClients(value);
|
||||
break;
|
||||
case 'blocked_hosts':
|
||||
setBlockedHosts(value);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const getFieldError = (id: keyof FormData) => {
|
||||
switch (id) {
|
||||
case 'allowed_clients':
|
||||
return allowedClientsError();
|
||||
case 'disallowed_clients':
|
||||
return disallowedClientsError();
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const validateField = (id: keyof FormData) => {
|
||||
const isIpField = id === 'allowed_clients' || id === 'disallowed_clients';
|
||||
if (!isIpField) return;
|
||||
const value = getFieldValue(id);
|
||||
const err = value ? validateIpPerLine(value) : undefined;
|
||||
if (id === 'allowed_clients') setAllowedClientsError(err || '');
|
||||
if (id === 'disallowed_clients') setDisallowedClientsError(err || '');
|
||||
};
|
||||
|
||||
const handleDisabledFieldState = (id: string) => {
|
||||
return id === 'disallowed_clients' && !!allowedClients();
|
||||
};
|
||||
|
||||
const getPlaceholder = (id: string) => {
|
||||
if (id === 'allowed_clients') {
|
||||
return intl.getMessage('access_settings_allowed_placeholder');
|
||||
}
|
||||
if (id === 'disallowed_clients') {
|
||||
return intl.getMessage('access_settings_disallowed_placeholder');
|
||||
}
|
||||
if (id === 'blocked_hosts') {
|
||||
return intl.getMessage('access_settings_blocked_placeholder');
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
validateField('allowed_clients');
|
||||
validateField('disallowed_clients');
|
||||
|
||||
if (allowedClientsError() || disallowedClientsError()) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.onSubmit({
|
||||
allowed_clients: allowedClients(),
|
||||
disallowed_clients: disallowedClients(),
|
||||
blocked_hosts: blockedHosts(),
|
||||
});
|
||||
};
|
||||
|
||||
const fields = createMemo<
|
||||
{
|
||||
id: keyof FormData;
|
||||
title: string;
|
||||
faq: JSX.Element;
|
||||
normalizeOnBlur: (value: string) => string;
|
||||
}[]
|
||||
>(() => [
|
||||
{
|
||||
id: 'allowed_clients',
|
||||
title: intl.getMessage('access_settings_allowed_title'),
|
||||
faq: intl.getMessage('access_settings_allowed_faq', {
|
||||
a: (text: string) => (
|
||||
<a href={CLIENT_ID_LINK} target="_blank" rel="noopener noreferrer">
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
}),
|
||||
normalizeOnBlur: removeEmptyLines,
|
||||
},
|
||||
{
|
||||
id: 'disallowed_clients',
|
||||
title: intl.getMessage('access_settings_disallowed_title'),
|
||||
faq: intl.getMessage('access_settings_disallowed_faq', {
|
||||
a: (text: string) => (
|
||||
<a href={CLIENT_ID_LINK} target="_blank" rel="noopener noreferrer">
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
}),
|
||||
normalizeOnBlur: trimMultilineString,
|
||||
},
|
||||
{
|
||||
id: 'blocked_hosts',
|
||||
title: intl.getMessage('access_settings_blocked_title'),
|
||||
faq: (
|
||||
<>
|
||||
<div>{intl.getMessage('access_settings_blocked_faq_1')}</div>
|
||||
<div>{intl.getMessage('access_settings_blocked_faq_2')}</div>
|
||||
</>
|
||||
),
|
||||
normalizeOnBlur: removeEmptyLines,
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} class={theme.form.form}>
|
||||
<div class={theme.form.group}>
|
||||
<For each={fields()}>
|
||||
{(f) => (
|
||||
<div class={theme.form.input}>
|
||||
<Textarea
|
||||
value={getFieldValue(f.id)}
|
||||
onChange={(e: Event) =>
|
||||
setFieldValue(f.id, (e.target as HTMLTextAreaElement).value)
|
||||
}
|
||||
onBlur={() => {
|
||||
const field = fields().find((ff) => ff.id === f.id);
|
||||
if (field) {
|
||||
setFieldValue(
|
||||
f.id,
|
||||
field.normalizeOnBlur(getFieldValue(f.id)),
|
||||
);
|
||||
}
|
||||
validateField(f.id);
|
||||
}}
|
||||
id={f.id}
|
||||
data-testid={f.id}
|
||||
label={
|
||||
<>
|
||||
{f.title}
|
||||
<FaqTooltip
|
||||
text={f.faq}
|
||||
menuSize="large"
|
||||
spacing={f.id === 'blocked_hosts'}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
errorMessage={getFieldError(f.id)}
|
||||
size="medium"
|
||||
disabled={handleDisabledFieldState(f.id)}
|
||||
placeholder={getPlaceholder(f.id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class={theme.form.buttonGroup}>
|
||||
<Button
|
||||
type="submit"
|
||||
id="access_save"
|
||||
variant="primary"
|
||||
size="small"
|
||||
disabled={props.processingSet}
|
||||
class={theme.form.button}
|
||||
>
|
||||
{intl.getMessage('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { untrack, type Accessor } from 'solid-js';
|
||||
|
||||
import { accessState, setAccessList } from 'panel/stores/access';
|
||||
import intl from 'panel/common/intl';
|
||||
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
|
||||
import { Textarea } from 'panel/common/controls/Textarea';
|
||||
import { validateClientsPerLine } from 'panel/helpers/validators';
|
||||
import { useField } from 'panel/hooks/useField';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
type Props = {
|
||||
open: Accessor<boolean>;
|
||||
onClose: () => void;
|
||||
processing: boolean;
|
||||
};
|
||||
|
||||
export const AllowedClientsDialog = (props: Props) => {
|
||||
const field = useField<string>(
|
||||
() => props.open(),
|
||||
() => accessState.allowed_clients,
|
||||
{ validate: (v) => (v ? validateClientsPerLine(v) || '' : '') },
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigDialog
|
||||
open={props.open()}
|
||||
title={intl.getMessage('dns_allowed_clients')}
|
||||
description={
|
||||
<>
|
||||
<p>{intl.getMessage('dns_allowed_clients_desc')}</p>
|
||||
<p>
|
||||
{intl.getMessage('dns_allowed_clients_desc_2', {
|
||||
a: (text: string) => (
|
||||
<a
|
||||
href="https://github.com/AdguardTeam/AdGuardHome/wiki/Clients#identifying-clients"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class={theme.link.link}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
onClose={props.onClose}
|
||||
onSubmit={() => {
|
||||
field.submitIfValid((v) => {
|
||||
setAccessList({ allowed_clients: v });
|
||||
untrack(() => props.onClose());
|
||||
});
|
||||
}}
|
||||
processing={props.processing}
|
||||
submitDisabled={!!field.error()}
|
||||
>
|
||||
<div class={theme.form.input}>
|
||||
<Textarea
|
||||
value={field.value()}
|
||||
onChange={(e: Event) => field.setValue((e.target as HTMLTextAreaElement).value)}
|
||||
onBlur={() => field.validate()}
|
||||
id="allowed_clients"
|
||||
label={intl.getMessage('dns_allowed_clients_label')}
|
||||
placeholder={intl.getMessage('dns_allowed_clients_placeholder')}
|
||||
size="medium"
|
||||
errorMessage={field.error()}
|
||||
/>
|
||||
</div>
|
||||
</ConfigDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { untrack, type Accessor } from 'solid-js';
|
||||
|
||||
import { accessState, setAccessList } from 'panel/stores/access';
|
||||
import intl from 'panel/common/intl';
|
||||
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
|
||||
import { Textarea } from 'panel/common/controls/Textarea';
|
||||
import { validateClientsPerLine } from 'panel/helpers/validators';
|
||||
import { useField } from 'panel/hooks/useField';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
type Props = {
|
||||
open: Accessor<boolean>;
|
||||
onClose: () => void;
|
||||
processing: boolean;
|
||||
};
|
||||
|
||||
export const DisallowedClientsDialog = (props: Props) => {
|
||||
const field = useField<string>(
|
||||
() => props.open(),
|
||||
() => accessState.disallowed_clients,
|
||||
{ validate: (v) => (v ? validateClientsPerLine(v) || '' : '') },
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigDialog
|
||||
open={props.open()}
|
||||
title={intl.getMessage('dns_disallowed_clients')}
|
||||
description={
|
||||
<>
|
||||
<p>{intl.getMessage('dns_disallowed_clients_desc')}</p>
|
||||
<p>
|
||||
{intl.getMessage('dns_allowed_clients_desc_2', {
|
||||
a: (text: string) => (
|
||||
<a
|
||||
href="https://github.com/AdguardTeam/AdGuardHome/wiki/Clients#identifying-clients"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class={theme.link.link}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
onClose={props.onClose}
|
||||
onSubmit={() => {
|
||||
field.submitIfValid((v) => {
|
||||
setAccessList({ disallowed_clients: v });
|
||||
untrack(() => props.onClose());
|
||||
});
|
||||
}}
|
||||
processing={props.processing}
|
||||
submitDisabled={!!field.error()}
|
||||
>
|
||||
<div class={theme.form.input}>
|
||||
<Textarea
|
||||
value={field.value()}
|
||||
onChange={(e: Event) => field.setValue((e.target as HTMLTextAreaElement).value)}
|
||||
onBlur={() => field.validate()}
|
||||
id="disallowed_clients"
|
||||
label={intl.getMessage('dns_disallowed_clients_label')}
|
||||
size="medium"
|
||||
errorMessage={field.error()}
|
||||
/>
|
||||
</div>
|
||||
</ConfigDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { untrack, type Accessor } from 'solid-js';
|
||||
|
||||
import { accessState, setAccessList } from 'panel/stores/access';
|
||||
import intl from 'panel/common/intl';
|
||||
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
|
||||
import { Textarea } from 'panel/common/controls/Textarea';
|
||||
import { validateDomainsPerLine } from 'panel/helpers/validators';
|
||||
import { useField } from 'panel/hooks/useField';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
type Props = {
|
||||
open: Accessor<boolean>;
|
||||
onClose: () => void;
|
||||
processing: boolean;
|
||||
};
|
||||
|
||||
export const DisallowedDomainsDialog = (props: Props) => {
|
||||
const field = useField<string>(
|
||||
() => props.open(),
|
||||
() => accessState.blocked_hosts,
|
||||
{ validate: (v) => (v ? validateDomainsPerLine(v) || '' : '') },
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigDialog
|
||||
open={props.open()}
|
||||
title={intl.getMessage('dns_disallowed_domains')}
|
||||
description={
|
||||
<>
|
||||
<p>{intl.getMessage('dns_disallowed_domains_desc')}</p>
|
||||
<p>{intl.getMessage('dns_disallowed_domains_desc_2')}</p>
|
||||
</>
|
||||
}
|
||||
onClose={props.onClose}
|
||||
onSubmit={() => {
|
||||
field.submitIfValid((v) => {
|
||||
setAccessList({ blocked_hosts: v });
|
||||
untrack(() => props.onClose());
|
||||
});
|
||||
}}
|
||||
processing={props.processing}
|
||||
submitDisabled={!!field.error()}
|
||||
>
|
||||
<div class={theme.form.input}>
|
||||
<Textarea
|
||||
value={field.value()}
|
||||
onChange={(e: Event) => field.setValue((e.target as HTMLTextAreaElement).value)}
|
||||
onBlur={() => field.validate()}
|
||||
id="blocked_hosts"
|
||||
label={intl.getMessage('dns_disallowed_domains_label')}
|
||||
size="medium"
|
||||
errorMessage={field.error()}
|
||||
/>
|
||||
</div>
|
||||
</ConfigDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
.section {
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 8px;
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: var(--fs-t2-normal);
|
||||
line-height: var(--lh-t2-normal);
|
||||
}
|
||||
@@ -1,33 +1,135 @@
|
||||
import { createSignal, createMemo, Show } from 'solid-js';
|
||||
import cn from 'clsx';
|
||||
|
||||
import { dnsConfigState, setDnsConfig } from 'panel/stores/dnsConfig';
|
||||
import { replaceEmptyStringsWithZeroes, replaceZeroWithEmptyString } from 'panel/helpers/helpers';
|
||||
import {
|
||||
dnsConfigState,
|
||||
clearDnsCache,
|
||||
toggleCacheEnabled,
|
||||
toggleOptimisticCaching,
|
||||
} from 'panel/stores/dnsConfig';
|
||||
import intl from 'panel/common/intl';
|
||||
import { Button } from 'panel/common/ui/Button';
|
||||
import { SettingRow } from 'panel/common/ui/SettingRow';
|
||||
import { ConfirmDialog } from 'panel/common/ui/ConfirmDialog';
|
||||
import { useDialogOpen } from 'panel/hooks/useField';
|
||||
import { getCacheSizeSummary, getTtlSummary } from '../helpers';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
import { Form } from './Form';
|
||||
import s from './Cache.module.pcss';
|
||||
import { CacheInputDialog } from './blocks/CacheInputDialog';
|
||||
|
||||
export const Cache = () => {
|
||||
const handleFormSubmit = (values: any) => {
|
||||
const completedFields = replaceEmptyStringsWithZeroes(values);
|
||||
setDnsConfig(completedFields, intl.getMessage('dns_cache_configuration_saved_toast'));
|
||||
};
|
||||
const [showClearConfirm, setShowClearConfirm] = createSignal(false);
|
||||
|
||||
const cacheSizeDialog = useDialogOpen();
|
||||
const minTtlDialog = useDialogOpen();
|
||||
const maxTtlDialog = useDialogOpen();
|
||||
|
||||
const cacheSizeValue = createMemo(() => getCacheSizeSummary(dnsConfigState.cache_size));
|
||||
const minTtlValue = createMemo(() => getTtlSummary(dnsConfigState.cache_ttl_min));
|
||||
const maxTtlValue = createMemo(() => getTtlSummary(dnsConfigState.cache_ttl_max));
|
||||
|
||||
const processing = () => dnsConfigState.processingSetConfig;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 class={cn(theme.layout.subtitle, theme.title.h5, theme.title.h4_tablet)}>
|
||||
{intl.getMessage('dns_cache_config')}
|
||||
</h2>
|
||||
<div class={s.section}>
|
||||
<SettingRow
|
||||
variant="switch"
|
||||
id="cache_enabled"
|
||||
title={intl.getMessage('dns_cache_title')}
|
||||
titleClass={cn(theme.title.h5, theme.title.h4_tablet, theme.text.bold, s.title)}
|
||||
descriptionClass={s.description}
|
||||
align="center"
|
||||
description={intl.getMessage('dns_cache_desc')}
|
||||
checked={!!dnsConfigState.cache_enabled}
|
||||
onChange={() => toggleCacheEnabled()}
|
||||
/>
|
||||
|
||||
<Form
|
||||
initialValues={{
|
||||
cache_enabled: dnsConfigState.cache_enabled,
|
||||
cache_size: replaceZeroWithEmptyString(dnsConfigState.cache_size),
|
||||
cache_ttl_max: replaceZeroWithEmptyString(dnsConfigState.cache_ttl_max),
|
||||
cache_ttl_min: replaceZeroWithEmptyString(dnsConfigState.cache_ttl_min),
|
||||
cache_optimistic: dnsConfigState.cache_optimistic,
|
||||
}}
|
||||
onSubmit={handleFormSubmit}
|
||||
<SettingRow
|
||||
variant="link"
|
||||
id="cache_size"
|
||||
title={intl.getMessage('dns_cache_size')}
|
||||
description={intl.getMessage('dns_cache_size_desc')}
|
||||
value={cacheSizeValue()}
|
||||
disabled={!dnsConfigState.cache_enabled}
|
||||
onClick={cacheSizeDialog.openDialog}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
variant="link"
|
||||
id="override_min_ttl"
|
||||
title={intl.getMessage('dns_override_min_ttl')}
|
||||
description={intl.getMessage('dns_override_min_ttl_desc')}
|
||||
value={minTtlValue()}
|
||||
disabled={!dnsConfigState.cache_enabled}
|
||||
onClick={minTtlDialog.openDialog}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
variant="link"
|
||||
id="override_max_ttl"
|
||||
title={intl.getMessage('dns_override_max_ttl')}
|
||||
description={intl.getMessage('dns_override_max_ttl_desc')}
|
||||
value={maxTtlValue()}
|
||||
disabled={!dnsConfigState.cache_enabled}
|
||||
onClick={maxTtlDialog.openDialog}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
variant="switch"
|
||||
id="optimistic_caching"
|
||||
title={intl.getMessage('dns_optimistic_caching')}
|
||||
description={intl.getMessage('dns_optimistic_caching_desc')}
|
||||
checked={!!dnsConfigState.cache_optimistic}
|
||||
disabled={!dnsConfigState.cache_enabled}
|
||||
onChange={() => toggleOptimisticCaching()}
|
||||
/>
|
||||
|
||||
<div class={theme.form.actionRow}>
|
||||
<Button
|
||||
variant="secondary-danger"
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
class={theme.form.actionButton}
|
||||
compact
|
||||
>
|
||||
{intl.getMessage('dns_clear_cache')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Show when={showClearConfirm()}>
|
||||
<ConfirmDialog
|
||||
title={intl.getMessage('dns_clear_cache_title')}
|
||||
text={intl.getMessage('dns_clear_cache_desc')}
|
||||
buttonText={intl.getMessage('dns_clear_cache_confirm')}
|
||||
cancelText={intl.getMessage('cancel')}
|
||||
buttonVariant="danger"
|
||||
onClose={() => setShowClearConfirm(false)}
|
||||
onConfirm={() => {
|
||||
clearDnsCache();
|
||||
setShowClearConfirm(false);
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<CacheInputDialog
|
||||
configKey="cache_size"
|
||||
open={cacheSizeDialog.open}
|
||||
onClose={cacheSizeDialog.closeDialog}
|
||||
processing={processing()}
|
||||
/>
|
||||
|
||||
<CacheInputDialog
|
||||
configKey="cache_ttl_min"
|
||||
open={minTtlDialog.open}
|
||||
onClose={minTtlDialog.closeDialog}
|
||||
processing={processing()}
|
||||
/>
|
||||
|
||||
<CacheInputDialog
|
||||
configKey="cache_ttl_max"
|
||||
open={maxTtlDialog.open}
|
||||
onClose={maxTtlDialog.closeDialog}
|
||||
processing={processing()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
import { createSignal, createMemo, For, Show } from 'solid-js';
|
||||
|
||||
import intl from 'panel/common/intl';
|
||||
import { dnsConfigState, clearDnsCache } from 'panel/stores/dnsConfig';
|
||||
import { CACHE_CONFIG_FIELDS, UINT32_RANGE } from 'panel/helpers/constants';
|
||||
import { Checkbox } from 'panel/common/controls/Checkbox';
|
||||
import { Input } from 'panel/common/controls/Input';
|
||||
import { Button } from 'panel/common/ui/Button';
|
||||
import { FaqTooltip } from 'panel/common/ui/FaqTooltip';
|
||||
import { ConfirmDialog } from 'panel/common/ui/ConfirmDialog';
|
||||
import theme from 'panel/lib/theme';
|
||||
import { formatNumber } from 'panel/helpers/helpers';
|
||||
|
||||
const CACHE_TTL_MAX_VALUE = 4_294_967_295;
|
||||
|
||||
const INPUTS_FIELDS = [
|
||||
{
|
||||
name: CACHE_CONFIG_FIELDS.cache_size,
|
||||
title: intl.getMessage('cache_config_size'),
|
||||
faq: intl.getMessage('cache_config_size_faq'),
|
||||
placeholder: intl.getMessage('enter_cache_size'),
|
||||
},
|
||||
{
|
||||
name: CACHE_CONFIG_FIELDS.cache_ttl_min,
|
||||
title: intl.getMessage('cache_config_min_ttl'),
|
||||
faq: intl.getMessage('cache_config_min_ttl_faq'),
|
||||
placeholder: intl.getMessage('cache_config_min_ttl_placeholder'),
|
||||
},
|
||||
{
|
||||
name: CACHE_CONFIG_FIELDS.cache_ttl_max,
|
||||
title: intl.getMessage('cache_config_max_ttl'),
|
||||
faq: intl.getMessage('cache_config_max_ttl_faq'),
|
||||
placeholder: intl.getMessage('cache_config_max_ttl_placeholder'),
|
||||
},
|
||||
];
|
||||
|
||||
type FormData = {
|
||||
cache_enabled: boolean;
|
||||
cache_size: number;
|
||||
cache_ttl_min: number;
|
||||
cache_ttl_max: number;
|
||||
cache_optimistic: boolean;
|
||||
};
|
||||
|
||||
type CacheFormProps = {
|
||||
initialValues?: Partial<FormData>;
|
||||
onSubmit: (data: FormData) => void;
|
||||
};
|
||||
|
||||
export const Form = (props: CacheFormProps) => {
|
||||
const [cacheEnabled, setCacheEnabled] = createSignal(
|
||||
props.initialValues?.cache_enabled || false,
|
||||
);
|
||||
const [cacheSize, setCacheSize] = createSignal(props.initialValues?.cache_size || 0);
|
||||
const [cacheTtlMin, setCacheTtlMin] = createSignal(props.initialValues?.cache_ttl_min || 0);
|
||||
const [cacheTtlMax, setCacheTtlMax] = createSignal(props.initialValues?.cache_ttl_max || 0);
|
||||
const [cacheOptimistic, setCacheOptimistic] = createSignal(
|
||||
props.initialValues?.cache_optimistic || false,
|
||||
);
|
||||
const [openConfirmClear, setOpenConfirmClear] = createSignal(false);
|
||||
|
||||
const minExceedsMax = createMemo(
|
||||
() => cacheTtlMin() > 0 && cacheTtlMax() > 0 && cacheTtlMin() > cacheTtlMax(),
|
||||
);
|
||||
|
||||
const cacheSizeZeroWhenEnabled = createMemo(() => cacheEnabled() && cacheSize() === 0);
|
||||
|
||||
const getFieldValue = (name: string): number => {
|
||||
switch (name) {
|
||||
case CACHE_CONFIG_FIELDS.cache_size:
|
||||
return cacheSize();
|
||||
case CACHE_CONFIG_FIELDS.cache_ttl_min:
|
||||
return cacheTtlMin();
|
||||
case CACHE_CONFIG_FIELDS.cache_ttl_max:
|
||||
return cacheTtlMax();
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const setFieldValue = (name: string, value: number) => {
|
||||
switch (name) {
|
||||
case CACHE_CONFIG_FIELDS.cache_size:
|
||||
setCacheSize(value);
|
||||
break;
|
||||
case CACHE_CONFIG_FIELDS.cache_ttl_min:
|
||||
setCacheTtlMin(value);
|
||||
break;
|
||||
case CACHE_CONFIG_FIELDS.cache_ttl_max:
|
||||
setCacheTtlMax(value);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCacheInputsValueError = (name: string) => {
|
||||
const value = getFieldValue(name);
|
||||
switch (name) {
|
||||
case CACHE_CONFIG_FIELDS.cache_size: {
|
||||
if (cacheSizeZeroWhenEnabled()) {
|
||||
return (
|
||||
<div class={theme.form.error}>
|
||||
{intl.getMessage('cache_config_size_validation')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (value > CACHE_TTL_MAX_VALUE || value < 0) {
|
||||
return (
|
||||
<div class={theme.form.error}>
|
||||
{intl.getMessage('form_value_length_common_error', {
|
||||
max_length: formatNumber(CACHE_TTL_MAX_VALUE),
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CACHE_CONFIG_FIELDS.cache_ttl_min: {
|
||||
if (value > CACHE_TTL_MAX_VALUE || value < 0) {
|
||||
return (
|
||||
<div class={theme.form.error}>
|
||||
{intl.getMessage('form_value_length_common_error', {
|
||||
max_length: formatNumber(CACHE_TTL_MAX_VALUE),
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CACHE_CONFIG_FIELDS.cache_ttl_max:
|
||||
if (value > CACHE_TTL_MAX_VALUE || value < 0) {
|
||||
return (
|
||||
<div class={theme.form.error}>
|
||||
{intl.getMessage('form_value_length_common_error', {
|
||||
max_length: formatNumber(CACHE_TTL_MAX_VALUE),
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
props.onSubmit({
|
||||
cache_enabled: cacheEnabled(),
|
||||
cache_size: cacheSize(),
|
||||
cache_ttl_min: cacheTtlMin(),
|
||||
cache_ttl_max: cacheTtlMax(),
|
||||
cache_optimistic: cacheOptimistic(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} class={theme.form.form}>
|
||||
<div class={theme.form.group}>
|
||||
<div class={theme.form.input}>
|
||||
<Checkbox
|
||||
name="cache_enabled"
|
||||
checked={cacheEnabled()}
|
||||
onChange={(e: Event) =>
|
||||
setCacheEnabled((e.target as HTMLInputElement).checked)
|
||||
}
|
||||
data-testid="dns_cache_enabled"
|
||||
verticalAlign="start"
|
||||
>
|
||||
<div>
|
||||
<div class={theme.text.t2}>{intl.getMessage('cache_enabled')}</div>
|
||||
<div class={theme.text.t4}>{intl.getMessage('cache_enabled_desc')}</div>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<For each={INPUTS_FIELDS}>
|
||||
{({ name, title, faq, placeholder }) => (
|
||||
<div class={theme.form.input}>
|
||||
<Input
|
||||
type="number"
|
||||
value={String(getFieldValue(name))}
|
||||
onChange={(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
setFieldValue(name, v === '' ? 0 : Number(v));
|
||||
}}
|
||||
id={name}
|
||||
label={
|
||||
<>
|
||||
{title}
|
||||
<FaqTooltip text={faq} menuSize="large" />
|
||||
</>
|
||||
}
|
||||
placeholder={placeholder}
|
||||
min={0}
|
||||
max={UINT32_RANGE.MAX}
|
||||
/>
|
||||
{handleCacheInputsValueError(name)}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={minExceedsMax()}>
|
||||
<div class={theme.form.error}>
|
||||
{intl.getMessage('cache_config_ttl_validation')}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class={theme.form.input}>
|
||||
<Checkbox
|
||||
name="cache_optimistic"
|
||||
checked={cacheOptimistic()}
|
||||
onChange={(e: Event) =>
|
||||
setCacheOptimistic((e.target as HTMLInputElement).checked)
|
||||
}
|
||||
data-testid="dns_cache_optimistic"
|
||||
verticalAlign="start"
|
||||
>
|
||||
<div>
|
||||
<div class={theme.text.t2}>
|
||||
{intl.getMessage('cache_config_optimistic')}
|
||||
</div>
|
||||
<div class={theme.text.t4}>
|
||||
{intl.getMessage('cache_config_optimistic_desc')}
|
||||
</div>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={theme.form.buttonGroup}>
|
||||
<Button
|
||||
type="submit"
|
||||
id="dns_save"
|
||||
variant="primary"
|
||||
size="small"
|
||||
disabled={
|
||||
dnsConfigState.processingSetConfig ||
|
||||
minExceedsMax() ||
|
||||
cacheSizeZeroWhenEnabled()
|
||||
}
|
||||
class={theme.form.button}
|
||||
>
|
||||
{intl.getMessage('save')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
id="dns_clear"
|
||||
variant="secondary-danger"
|
||||
size="small"
|
||||
onClick={() => setOpenConfirmClear(true)}
|
||||
class={theme.form.button}
|
||||
>
|
||||
{intl.getMessage('cache_config_clear')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Show when={openConfirmClear()}>
|
||||
<ConfirmDialog
|
||||
onClose={() => setOpenConfirmClear(false)}
|
||||
onConfirm={() => {
|
||||
clearDnsCache();
|
||||
setOpenConfirmClear(false);
|
||||
}}
|
||||
buttonText={intl.getMessage('confirm')}
|
||||
cancelText={intl.getMessage('cancel')}
|
||||
title={intl.getMessage('cache_confirm_clear_title')}
|
||||
text={intl.getMessage('cache_confirm_clear_desc')}
|
||||
buttonVariant="danger"
|
||||
/>
|
||||
</Show>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { untrack, type Accessor } from 'solid-js';
|
||||
import { dnsConfigState, setDnsConfig } from 'panel/stores/dnsConfig';
|
||||
import intl from 'panel/common/intl';
|
||||
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
|
||||
import { Input } from 'panel/common/controls/Input';
|
||||
import { UINT32_RANGE } from 'panel/helpers/constants';
|
||||
import { validateBetween, validateRequiredValue } from 'panel/helpers/validators';
|
||||
import { useField } from 'panel/hooks/useField';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
type CacheConfigKey = 'cache_size' | 'cache_ttl_min' | 'cache_ttl_max';
|
||||
|
||||
type Props = {
|
||||
configKey: CacheConfigKey;
|
||||
open: Accessor<boolean>;
|
||||
onClose: () => void;
|
||||
processing: boolean;
|
||||
};
|
||||
|
||||
const CACHE_DIALOG_CONFIG: Record<
|
||||
CacheConfigKey,
|
||||
{
|
||||
title: () => string;
|
||||
description: () => string;
|
||||
label: () => string;
|
||||
placeholder?: () => string;
|
||||
validate: (v: string) => string;
|
||||
}
|
||||
> = {
|
||||
cache_size: {
|
||||
title: () => intl.getMessage('dns_cache_size_title'),
|
||||
description: () => intl.getMessage('dns_cache_size_desc'),
|
||||
label: () => intl.getMessage('dns_cache_size_label'),
|
||||
validate: (v) =>
|
||||
validateRequiredValue(v) ||
|
||||
validateBetween(Number(v), UINT32_RANGE.MIN, UINT32_RANGE.MAX) ||
|
||||
'',
|
||||
},
|
||||
cache_ttl_min: {
|
||||
title: () => intl.getMessage('dns_override_min_ttl_title'),
|
||||
description: () => intl.getMessage('dns_override_min_ttl_desc'),
|
||||
label: () => intl.getMessage('dns_override_min_ttl_label'),
|
||||
placeholder: () => intl.getMessage('dns_override_min_ttl_placeholder'),
|
||||
validate: (v) =>
|
||||
validateRequiredValue(v) ||
|
||||
validateBetween(Number(v), UINT32_RANGE.MIN, UINT32_RANGE.MAX) ||
|
||||
'',
|
||||
},
|
||||
cache_ttl_max: {
|
||||
title: () => intl.getMessage('dns_override_max_ttl_title'),
|
||||
description: () => intl.getMessage('dns_override_max_ttl_desc'),
|
||||
label: () => intl.getMessage('dns_override_max_ttl_label'),
|
||||
placeholder: () => intl.getMessage('dns_override_max_ttl_placeholder'),
|
||||
validate: (v) => {
|
||||
const requiredErr = validateRequiredValue(v);
|
||||
if (requiredErr) return requiredErr;
|
||||
const num = Number(v);
|
||||
const rangeErr = validateBetween(num, UINT32_RANGE.MIN, UINT32_RANGE.MAX);
|
||||
if (rangeErr) return rangeErr;
|
||||
// Cross-field TTL check: min > max
|
||||
const minVal = Number(dnsConfigState.cache_ttl_min);
|
||||
if (minVal > 0 && num > 0 && minVal > num) {
|
||||
return intl.getMessage('cache_config_ttl_validation');
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CacheInputDialog = (props: Props) => {
|
||||
const config = () => CACHE_DIALOG_CONFIG[props.configKey];
|
||||
|
||||
const field = useField<string>(
|
||||
() => props.open(),
|
||||
() => String(dnsConfigState[props.configKey] ?? 0),
|
||||
{ validate: config().validate },
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigDialog
|
||||
open={props.open()}
|
||||
title={config().title()}
|
||||
description={config().description()}
|
||||
onClose={props.onClose}
|
||||
onSubmit={() => {
|
||||
const configKey = props.configKey;
|
||||
field.submitIfValid((v) => {
|
||||
setDnsConfig({ [configKey]: v === '' ? 0 : Number(v) });
|
||||
untrack(() => props.onClose());
|
||||
});
|
||||
}}
|
||||
processing={props.processing}
|
||||
submitDisabled={!!field.error()}
|
||||
>
|
||||
<div class={theme.form.input}>
|
||||
<Input
|
||||
type="number"
|
||||
value={field.value()}
|
||||
onChange={(e: Event) => field.setValue((e.target as HTMLInputElement).value)}
|
||||
onBlur={() => field.validate()}
|
||||
id={props.configKey}
|
||||
label={config().label()}
|
||||
placeholder={config().placeholder?.()}
|
||||
min={UINT32_RANGE.MIN}
|
||||
max={UINT32_RANGE.MAX}
|
||||
errorMessage={field.error()}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
</ConfigDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
.title {
|
||||
margin-bottom: 8px;
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin-bottom: 12px;
|
||||
font-size: var(--fs-text-t2);
|
||||
line-height: var(--lh-t2-normal);
|
||||
color: var(--default-main-text);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.form {
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
padding: 0 16px;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createMemo } from 'solid-js';
|
||||
import cn from 'clsx';
|
||||
|
||||
import {
|
||||
dnsConfigState,
|
||||
togglePrivatePtrResolvers,
|
||||
toggleResolveClients,
|
||||
} from 'panel/stores/dnsConfig';
|
||||
import intl from 'panel/common/intl';
|
||||
import { Breadcrumbs } from 'panel/common/ui/Breadcrumbs';
|
||||
import { SettingRow } from 'panel/common/ui/SettingRow';
|
||||
import { RoutePath } from 'panel/components/Routes/Paths';
|
||||
import { useDialogOpen } from 'panel/hooks/useField';
|
||||
import { PrivateReverseServersDialog } from '../Upstream/blocks/PrivateReverseServersDialog';
|
||||
import { getUpstreamServersSummary } from '../helpers';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
import s from './PrivateReverse.module.pcss';
|
||||
|
||||
export const PrivateReverse = () => {
|
||||
const serversDialog = useDialogOpen();
|
||||
|
||||
const processing = () => dnsConfigState.processingSetConfig;
|
||||
|
||||
const privateReverseValue = createMemo(() =>
|
||||
getUpstreamServersSummary(dnsConfigState.local_ptr_upstreams),
|
||||
);
|
||||
|
||||
return (
|
||||
<div class={cn(theme.layout.container, s.container)}>
|
||||
<div class={cn(theme.layout.containerIn, theme.layout.containerIn_one_col)}>
|
||||
<div class={s.breadcrumbs}>
|
||||
<Breadcrumbs
|
||||
parentLinks={[
|
||||
{
|
||||
path: RoutePath.Dns,
|
||||
title: intl.getMessage('dns_settings'),
|
||||
},
|
||||
]}
|
||||
currentTitle={intl.getMessage('dns_private_reverse_resolvers')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={s.form}>
|
||||
<SettingRow
|
||||
variant="switch"
|
||||
id="use_private_ptr_resolvers"
|
||||
title={intl.getMessage('dns_private_reverse_resolvers')}
|
||||
titleClass={cn(theme.title.h4, theme.title.h3_tablet, s.title)}
|
||||
description={
|
||||
<>
|
||||
<p class={s.desc}>
|
||||
{intl.getMessage('dns_private_reverse_resolvers_desc')}
|
||||
</p>
|
||||
<p class={s.desc}>
|
||||
{intl.getMessage('dns_private_reverse_resolvers_disabled_desc')}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
checked={dnsConfigState.use_private_ptr_resolvers}
|
||||
onChange={() => togglePrivatePtrResolvers()}
|
||||
largeTitle
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
variant="link"
|
||||
id="private_reverse_servers"
|
||||
title={intl.getMessage('dns_private_reverse_servers_title')}
|
||||
description={intl.getMessage('dns_private_reverse_servers_desc')}
|
||||
value={privateReverseValue()}
|
||||
onClick={serversDialog.openDialog}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
variant="switch"
|
||||
id="resolve_clients"
|
||||
title={intl.getMessage('dns_private_reverse_resolve_clients_title')}
|
||||
description={intl.getMessage('dns_private_reverse_resolve_clients_desc')}
|
||||
checked={dnsConfigState.resolve_clients}
|
||||
onChange={() => toggleResolveClients()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PrivateReverseServersDialog
|
||||
open={serversDialog.open}
|
||||
onClose={serversDialog.closeDialog}
|
||||
processing={processing()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { PrivateReverse } from './PrivateReverse';
|
||||
@@ -1,14 +1,40 @@
|
||||
import { createMemo } from 'solid-js';
|
||||
import cn from 'clsx';
|
||||
|
||||
import { dnsConfigState, setDnsConfig } from 'panel/stores/dnsConfig';
|
||||
import {
|
||||
dnsConfigState,
|
||||
toggleDnssecEnabled,
|
||||
toggleDisableIPv6,
|
||||
toggleEdnsCsEnabled,
|
||||
} from 'panel/stores/dnsConfig';
|
||||
import intl from 'panel/common/intl';
|
||||
import { SettingRow } from 'panel/common/ui/SettingRow';
|
||||
import { getRateLimitSummary, getBlockingModeSummary, getUpstreamServersSummary } from '../helpers';
|
||||
import theme from 'panel/lib/theme';
|
||||
import { Form } from './blocks/Form';
|
||||
import { IP_VERSION, IPV4_SUBNET_PREFIX, IPV6_SUBNET_PREFIX } from 'panel/helpers/constants';
|
||||
import { useDialogOpen } from 'panel/hooks/useField';
|
||||
|
||||
import { RateLimitDialog } from './blocks/RateLimitDialog';
|
||||
import { SubnetPrefixV4Dialog } from './blocks/SubnetPrefixV4Dialog';
|
||||
import { SubnetPrefixV6Dialog } from './blocks/SubnetPrefixV6Dialog';
|
||||
import { BlockingModeDialog } from './blocks/BlockingModeDialog';
|
||||
import { RateLimitAllowlistDialog } from './blocks/RateLimitAllowlistDialog';
|
||||
import { EdnsDialog } from './blocks/EdnsDialog';
|
||||
|
||||
export const ServerConfig = () => {
|
||||
const handleFormSubmit = (values: any) => {
|
||||
setDnsConfig(values, intl.getMessage('dns_server_configuration_saved_toast'));
|
||||
};
|
||||
const rateLimitDialog = useDialogOpen();
|
||||
const subnetV4Dialog = useDialogOpen();
|
||||
const subnetV6Dialog = useDialogOpen();
|
||||
const blockingModeDialog = useDialogOpen();
|
||||
const allowlistDialog = useDialogOpen();
|
||||
const ednsDialog = useDialogOpen();
|
||||
|
||||
const rateLimitValue = createMemo(() => getRateLimitSummary(dnsConfigState.ratelimit));
|
||||
const blockingModeValue = createMemo(() =>
|
||||
getBlockingModeSummary(dnsConfigState.blocking_mode),
|
||||
);
|
||||
|
||||
const processing = () => dnsConfigState.processingSetConfig;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -16,24 +42,118 @@ export const ServerConfig = () => {
|
||||
{intl.getMessage('dns_config')}
|
||||
</h2>
|
||||
|
||||
<Form
|
||||
initialValues={{
|
||||
ratelimit: dnsConfigState.ratelimit,
|
||||
ratelimit_subnet_len_ipv4: dnsConfigState.ratelimit_subnet_len_ipv4,
|
||||
ratelimit_subnet_len_ipv6: dnsConfigState.ratelimit_subnet_len_ipv6,
|
||||
ratelimit_whitelist: dnsConfigState.ratelimit_whitelist,
|
||||
blocking_mode: dnsConfigState.blocking_mode,
|
||||
blocking_ipv4: dnsConfigState.blocking_ipv4,
|
||||
blocking_ipv6: dnsConfigState.blocking_ipv6,
|
||||
blocked_response_ttl: dnsConfigState.blocked_response_ttl,
|
||||
edns_cs_enabled: dnsConfigState.edns_cs_enabled,
|
||||
disable_ipv6: dnsConfigState.disable_ipv6,
|
||||
dnssec_enabled: dnsConfigState.dnssec_enabled,
|
||||
edns_cs_use_custom: dnsConfigState.edns_cs_use_custom,
|
||||
edns_cs_custom_ip: dnsConfigState.edns_cs_custom_ip,
|
||||
}}
|
||||
onSubmit={handleFormSubmit}
|
||||
processing={dnsConfigState.processingSetConfig}
|
||||
<SettingRow
|
||||
variant="link"
|
||||
id="rate_limit"
|
||||
title={intl.getMessage('dns_rate_limit')}
|
||||
description={intl.getMessage('dns_rate_limit_desc')}
|
||||
value={rateLimitValue()}
|
||||
onClick={rateLimitDialog.openDialog}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
variant="link"
|
||||
id="subnet_ipv4"
|
||||
title={intl.getMessage('dns_subnet_prefix', { value: IP_VERSION.V4 })}
|
||||
description={intl.getMessage('dns_subnet_prefix_desc', { value: IP_VERSION.V4 })}
|
||||
value={String(
|
||||
dnsConfigState.ratelimit_subnet_len_ipv4 ?? IPV4_SUBNET_PREFIX.DEFAULT,
|
||||
)}
|
||||
onClick={subnetV4Dialog.openDialog}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
variant="link"
|
||||
id="subnet_ipv6"
|
||||
title={intl.getMessage('dns_subnet_prefix', { value: IP_VERSION.V6 })}
|
||||
description={intl.getMessage('dns_subnet_prefix_desc', { value: IP_VERSION.V6 })}
|
||||
value={String(
|
||||
dnsConfigState.ratelimit_subnet_len_ipv6 ?? IPV6_SUBNET_PREFIX.DEFAULT,
|
||||
)}
|
||||
onClick={subnetV6Dialog.openDialog}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
variant="link"
|
||||
id="blocking_mode"
|
||||
title={intl.getMessage('dns_blocking_mode')}
|
||||
description={intl.getMessage('dns_blocking_mode_desc')}
|
||||
value={blockingModeValue()}
|
||||
onClick={blockingModeDialog.openDialog}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
variant="link"
|
||||
id="ratelimit_allowlist"
|
||||
title={intl.getMessage('dns_rate_limit_allowlist')}
|
||||
description={intl.getMessage('dns_rate_limit_allowlist_desc')}
|
||||
value={getUpstreamServersSummary(dnsConfigState.ratelimit_whitelist)}
|
||||
onClick={allowlistDialog.openDialog}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
variant="switch-link"
|
||||
id="edns_client_subnet"
|
||||
title={intl.getMessage('dns_edns_client_subnet')}
|
||||
description={intl.getMessage('dns_edns_client_subnet_desc')}
|
||||
checked={dnsConfigState.edns_cs_enabled}
|
||||
onChange={() => toggleEdnsCsEnabled()}
|
||||
onClick={ednsDialog.openDialog}
|
||||
divider
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
variant="switch"
|
||||
id="dnssec"
|
||||
title={intl.getMessage('dns_dnssec')}
|
||||
description={intl.getMessage('dns_dnssec_desc')}
|
||||
checked={dnsConfigState.dnssec_enabled}
|
||||
onChange={() => toggleDnssecEnabled()}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
variant="switch"
|
||||
id="ipv6_resolution"
|
||||
title={intl.getMessage('dns_ipv6_resolution')}
|
||||
description={intl.getMessage('dns_ipv6_resolution_desc')}
|
||||
checked={!dnsConfigState.disable_ipv6}
|
||||
onChange={() => toggleDisableIPv6()}
|
||||
/>
|
||||
|
||||
<RateLimitDialog
|
||||
open={rateLimitDialog.open}
|
||||
onClose={rateLimitDialog.closeDialog}
|
||||
processing={processing()}
|
||||
/>
|
||||
|
||||
<SubnetPrefixV4Dialog
|
||||
open={subnetV4Dialog.open}
|
||||
onClose={subnetV4Dialog.closeDialog}
|
||||
processing={processing()}
|
||||
/>
|
||||
|
||||
<SubnetPrefixV6Dialog
|
||||
open={subnetV6Dialog.open}
|
||||
onClose={subnetV6Dialog.closeDialog}
|
||||
processing={processing()}
|
||||
/>
|
||||
|
||||
<BlockingModeDialog
|
||||
open={blockingModeDialog.open}
|
||||
onClose={blockingModeDialog.closeDialog}
|
||||
processing={processing()}
|
||||
/>
|
||||
|
||||
<RateLimitAllowlistDialog
|
||||
open={allowlistDialog.open}
|
||||
onClose={allowlistDialog.closeDialog}
|
||||
processing={processing()}
|
||||
/>
|
||||
|
||||
<EdnsDialog
|
||||
open={ednsDialog.open}
|
||||
onClose={ednsDialog.closeDialog}
|
||||
processing={processing()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { createSignal, createEffect, type Accessor, Show } from 'solid-js';
|
||||
|
||||
import { dnsConfigState, setDnsConfig } from 'panel/stores/dnsConfig';
|
||||
import intl from 'panel/common/intl';
|
||||
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
|
||||
import { Input } from 'panel/common/controls/Input';
|
||||
import { Radio } from 'panel/common/controls/Radio';
|
||||
import { BLOCKING_MODES, UINT32_RANGE } from 'panel/helpers/constants';
|
||||
import { getBlockingModeOptions } from '../../helpers';
|
||||
import {
|
||||
validateRequiredValue,
|
||||
validateBetween,
|
||||
validateIpv4,
|
||||
validateIpv6,
|
||||
} from 'panel/helpers/validators';
|
||||
import { useField } from 'panel/hooks/useField';
|
||||
import { FaqTooltip } from 'panel/common/ui/FaqTooltip';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
type Props = {
|
||||
open: Accessor<boolean>;
|
||||
onClose: () => void;
|
||||
processing: boolean;
|
||||
};
|
||||
|
||||
export const BlockingModeDialog = (props: Props) => {
|
||||
const blockingModeOptions = getBlockingModeOptions();
|
||||
|
||||
const [blockingMode, setBlockingMode] = createSignal(dnsConfigState.blocking_mode);
|
||||
createEffect(() => {
|
||||
if (props.open()) {
|
||||
setBlockingMode(dnsConfigState.blocking_mode);
|
||||
}
|
||||
});
|
||||
|
||||
const blockingIpv4 = useField<string>(
|
||||
() => props.open(),
|
||||
() => dnsConfigState.blocking_ipv4,
|
||||
{
|
||||
validate: (v) => validateRequiredValue(v) || validateIpv4(v) || '',
|
||||
},
|
||||
);
|
||||
const blockingIpv6 = useField<string>(
|
||||
() => props.open(),
|
||||
() => dnsConfigState.blocking_ipv6,
|
||||
{
|
||||
validate: (v) => validateRequiredValue(v) || validateIpv6(v) || '',
|
||||
},
|
||||
);
|
||||
const ttl = useField<number>(
|
||||
() => props.open(),
|
||||
() => dnsConfigState.blocked_response_ttl,
|
||||
{
|
||||
validate: (v) => {
|
||||
if (Number.isNaN(v)) {
|
||||
return intl.getMessage('form_error_required');
|
||||
}
|
||||
return validateBetween(v, UINT32_RANGE.MIN, UINT32_RANGE.MAX) || '';
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const isCustom = blockingMode() === BLOCKING_MODES.custom_ip;
|
||||
if (isCustom && (blockingIpv4.validate() || blockingIpv6.validate())) return;
|
||||
if (ttl.validate()) return;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
blocking_mode: blockingMode(),
|
||||
blocked_response_ttl: ttl.value(),
|
||||
};
|
||||
if (blockingMode() === BLOCKING_MODES.custom_ip) {
|
||||
payload.blocking_ipv4 = blockingIpv4.value();
|
||||
payload.blocking_ipv6 = blockingIpv6.value();
|
||||
}
|
||||
setDnsConfig(payload);
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
const submitDisabled = () => {
|
||||
if (blockingMode() === BLOCKING_MODES.custom_ip) {
|
||||
return !!(blockingIpv4.error() || blockingIpv6.error() || ttl.error());
|
||||
}
|
||||
return !!ttl.error();
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigDialog
|
||||
open={props.open()}
|
||||
title={intl.getMessage('dns_blocking_mode_title')}
|
||||
description={intl.getMessage('dns_blocking_mode_desc')}
|
||||
onClose={props.onClose}
|
||||
onSubmit={handleSubmit}
|
||||
processing={props.processing}
|
||||
submitDisabled={submitDisabled()}
|
||||
>
|
||||
<Radio
|
||||
name="blocking_mode"
|
||||
options={blockingModeOptions}
|
||||
value={blockingMode()}
|
||||
handleChange={(v: string) => setBlockingMode(v)}
|
||||
inModal
|
||||
/>
|
||||
<Show when={blockingMode() === BLOCKING_MODES.custom_ip}>
|
||||
<>
|
||||
<div class={theme.form.input}>
|
||||
<Input
|
||||
id="blocking_ipv4"
|
||||
label={
|
||||
<>
|
||||
{intl.getMessage('dns_blocking_mode_ipv4_label')}
|
||||
<FaqTooltip
|
||||
text={intl.getMessage(
|
||||
'server_config_blocking_mode_ipv4_faq',
|
||||
)}
|
||||
menuSize="large"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
placeholder={intl.getMessage('dns_blocking_mode_ipv4_placeholder')}
|
||||
value={blockingIpv4.value()}
|
||||
onChange={(e: Event) =>
|
||||
blockingIpv4.setValue((e.target as HTMLInputElement).value)
|
||||
}
|
||||
onBlur={() => blockingIpv4.validate()}
|
||||
errorMessage={blockingIpv4.error()}
|
||||
disabled={blockingMode() !== BLOCKING_MODES.custom_ip}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<div class={theme.form.input}>
|
||||
<Input
|
||||
id="blocking_ipv6"
|
||||
label={
|
||||
<>
|
||||
{intl.getMessage('dns_blocking_mode_ipv6_label')}
|
||||
<FaqTooltip
|
||||
text={intl.getMessage(
|
||||
'server_config_blocking_mode_ipv6_faq',
|
||||
)}
|
||||
menuSize="large"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
placeholder={intl.getMessage('dns_blocking_mode_ipv6_placeholder')}
|
||||
value={blockingIpv6.value()}
|
||||
onChange={(e: Event) =>
|
||||
blockingIpv6.setValue((e.target as HTMLInputElement).value)
|
||||
}
|
||||
onBlur={() => blockingIpv6.validate()}
|
||||
errorMessage={blockingIpv6.error()}
|
||||
disabled={blockingMode() !== BLOCKING_MODES.custom_ip}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</Show>
|
||||
<div class={theme.form.input}>
|
||||
<Input
|
||||
type="number"
|
||||
id="blocked_response_ttl"
|
||||
label={
|
||||
<>
|
||||
{intl.getMessage('dns_blocking_mode_ttl_label')}
|
||||
<FaqTooltip
|
||||
text={intl.getMessage('server_config_blocking_mode_ttl_faq')}
|
||||
menuSize="large"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
placeholder={intl.getMessage('dns_blocking_mode_ttl_placeholder')}
|
||||
value={ttl.value()}
|
||||
onChange={(e: Event) =>
|
||||
ttl.setValue(
|
||||
(e.target as HTMLInputElement).value === ''
|
||||
? NaN
|
||||
: Number((e.target as HTMLInputElement).value),
|
||||
)
|
||||
}
|
||||
onBlur={() => ttl.validate()}
|
||||
min={UINT32_RANGE.MIN}
|
||||
max={UINT32_RANGE.MAX}
|
||||
errorMessage={ttl.error()}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
</ConfigDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { createSignal, createEffect, type Accessor } from 'solid-js';
|
||||
|
||||
import { dnsConfigState, setDnsConfig } from 'panel/stores/dnsConfig';
|
||||
import intl from 'panel/common/intl';
|
||||
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
|
||||
import { Input } from 'panel/common/controls/Input';
|
||||
import { Radio } from 'panel/common/controls/Radio';
|
||||
import { EDNS_MODES } from 'panel/helpers/constants';
|
||||
import { getEdnsOptions } from '../../helpers';
|
||||
import { validateRequiredValue, validateIp } from 'panel/helpers/validators';
|
||||
import { useField } from 'panel/hooks/useField';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
type Props = {
|
||||
open: Accessor<boolean>;
|
||||
onClose: () => void;
|
||||
processing: boolean;
|
||||
};
|
||||
|
||||
export const EdnsDialog = (props: Props) => {
|
||||
const ednsOptions = getEdnsOptions();
|
||||
|
||||
// EDNS custom flag — plain signal synced from store
|
||||
const [ednsCsUseCustom, setEdnsCsUseCustom] = createSignal(
|
||||
dnsConfigState.edns_cs_use_custom ?? false,
|
||||
);
|
||||
createEffect(() => {
|
||||
if (props.open()) {
|
||||
setEdnsCsUseCustom(dnsConfigState.edns_cs_use_custom ?? false);
|
||||
}
|
||||
});
|
||||
|
||||
// Custom IP field — conditionally validated
|
||||
const ednsCsCustomIp = useField<string>(
|
||||
() => props.open(),
|
||||
() => dnsConfigState.edns_cs_custom_ip ?? '',
|
||||
{
|
||||
validate: (v) =>
|
||||
ednsCsUseCustom() ? validateRequiredValue(v) || validateIp(v) || '' : '',
|
||||
},
|
||||
);
|
||||
|
||||
// Clear custom IP error when switching away from custom mode
|
||||
createEffect(() => {
|
||||
if (!ednsCsUseCustom()) {
|
||||
ednsCsCustomIp.setError('');
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
// Bugfix: submit-time validation
|
||||
if (ednsCsUseCustom() && ednsCsCustomIp.validate()) return;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
edns_cs_use_custom: ednsCsUseCustom(),
|
||||
};
|
||||
if (ednsCsUseCustom()) {
|
||||
payload.edns_cs_custom_ip = ednsCsCustomIp.value();
|
||||
}
|
||||
setDnsConfig(payload);
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigDialog
|
||||
open={props.open()}
|
||||
title={intl.getMessage('dns_edns_title')}
|
||||
description={intl.getMessage('dns_edns_desc')}
|
||||
onClose={props.onClose}
|
||||
onSubmit={handleSubmit}
|
||||
processing={props.processing}
|
||||
submitDisabled={!!(ednsCsUseCustom() && ednsCsCustomIp.error())}
|
||||
>
|
||||
<div class={theme.form.input}>
|
||||
<Radio
|
||||
name="edns_cs_mode"
|
||||
options={ednsOptions}
|
||||
value={ednsCsUseCustom() ? EDNS_MODES.custom : EDNS_MODES.default}
|
||||
handleChange={(v: string) => setEdnsCsUseCustom(v === EDNS_MODES.custom)}
|
||||
inModal
|
||||
/>
|
||||
<Input
|
||||
id="edns_cs_custom_ip"
|
||||
label={intl.getMessage('dns_edns_custom_label')}
|
||||
placeholder={intl.getMessage('dns_edns_custom_placeholder')}
|
||||
value={ednsCsCustomIp.value()}
|
||||
onChange={(e: Event) =>
|
||||
ednsCsCustomIp.setValue((e.target as HTMLInputElement).value)
|
||||
}
|
||||
onBlur={() => ednsCsCustomIp.validate()}
|
||||
disabled={!ednsCsUseCustom()}
|
||||
errorMessage={ednsCsCustomIp.error()}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
</ConfigDialog>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
.subtitle {
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.descriptions {
|
||||
padding: 12px 0 8px;
|
||||
}
|
||||
@@ -1,565 +0,0 @@
|
||||
import { createSignal, createMemo, For, Show } from 'solid-js';
|
||||
import cn from 'clsx';
|
||||
|
||||
import intl from 'panel/common/intl';
|
||||
import {
|
||||
validateBetween,
|
||||
validateIp,
|
||||
validateIpPerLine,
|
||||
validateIpv4,
|
||||
validateIpv6,
|
||||
validateMaxValue,
|
||||
validateRequiredValue,
|
||||
} from 'panel/helpers/validators';
|
||||
import {
|
||||
BLOCKING_MODES,
|
||||
IPV4_SUBNET_PREFIX,
|
||||
IPV6_SUBNET_PREFIX,
|
||||
RATE_LIMIT,
|
||||
UINT32_RANGE,
|
||||
} from 'panel/helpers/constants';
|
||||
import { Checkbox } from 'panel/common/controls/Checkbox';
|
||||
import { Input } from 'panel/common/controls/Input';
|
||||
import { Textarea } from 'panel/common/controls/Textarea';
|
||||
import { Radio } from 'panel/common/controls/Radio';
|
||||
import { Button } from 'panel/common/ui/Button';
|
||||
import { FaqTooltip } from 'panel/common/ui/FaqTooltip';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
import s from './Form.module.pcss';
|
||||
|
||||
const blockingModesDescriptions = [
|
||||
intl.getMessage('server_config_blocking_mode_default_desc'),
|
||||
intl.getMessage('server_config_blocking_mode_refused_desc'),
|
||||
intl.getMessage('server_config_blocking_mode_nxdomain_desc'),
|
||||
intl.getMessage('server_config_blocking_mode_null_ip_desc'),
|
||||
intl.getMessage('server_config_blocking_mode_custom_ip_desc'),
|
||||
];
|
||||
|
||||
const checkboxes: {
|
||||
name: 'dnssec_enabled' | 'disable_ipv6';
|
||||
placeholder: string;
|
||||
subtitle: string;
|
||||
}[] = [
|
||||
{
|
||||
name: 'dnssec_enabled',
|
||||
placeholder: intl.getMessage('server_config_dnssec_enable'),
|
||||
subtitle: intl.getMessage('server_config_dnssec_enable_desc'),
|
||||
},
|
||||
{
|
||||
name: 'disable_ipv6',
|
||||
placeholder: intl.getMessage('server_config_disable_ipv6'),
|
||||
subtitle: intl.getMessage('server_config_disable_ipv6_desc'),
|
||||
},
|
||||
];
|
||||
|
||||
const customIps: {
|
||||
name: 'blocking_ipv4' | 'blocking_ipv6';
|
||||
label: string;
|
||||
placeholder: string;
|
||||
faq: string;
|
||||
validateIp: (value: string) => string;
|
||||
}[] = [
|
||||
{
|
||||
name: 'blocking_ipv4',
|
||||
label: intl.getMessage('server_config_blocking_mode_ipv4'),
|
||||
placeholder: intl.getMessage('server_config_blocking_mode_ipv4_placeholder'),
|
||||
faq: intl.getMessage('server_config_blocking_mode_ipv4_faq'),
|
||||
validateIp: validateIpv4,
|
||||
},
|
||||
{
|
||||
name: 'blocking_ipv6',
|
||||
label: intl.getMessage('server_config_blocking_mode_ipv6'),
|
||||
placeholder: intl.getMessage('server_config_blocking_mode_ipv6_placeholder'),
|
||||
faq: intl.getMessage('server_config_blocking_mode_ipv6_faq'),
|
||||
validateIp: validateIpv6,
|
||||
},
|
||||
];
|
||||
|
||||
const blockingModeOptions = [
|
||||
{ value: BLOCKING_MODES.default, text: intl.getMessage('server_config_default') },
|
||||
{ value: BLOCKING_MODES.refused, text: intl.getMessage('server_config_refused') },
|
||||
{ value: BLOCKING_MODES.nxdomain, text: intl.getMessage('server_config_nxdomain') },
|
||||
{ value: BLOCKING_MODES.null_ip, text: intl.getMessage('server_config_null_ip') },
|
||||
{ value: BLOCKING_MODES.custom_ip, text: intl.getMessage('server_config_custom_ip') },
|
||||
];
|
||||
|
||||
type FormData = {
|
||||
ratelimit: number;
|
||||
ratelimit_subnet_len_ipv4: number;
|
||||
ratelimit_subnet_len_ipv6: number;
|
||||
ratelimit_whitelist: string;
|
||||
edns_cs_enabled: boolean;
|
||||
edns_cs_use_custom: boolean;
|
||||
edns_cs_custom_ip?: string;
|
||||
dnssec_enabled: boolean;
|
||||
disable_ipv6: boolean;
|
||||
blocking_mode: string;
|
||||
blocking_ipv4?: string;
|
||||
blocking_ipv6?: string;
|
||||
blocked_response_ttl: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
processing?: boolean;
|
||||
initialValues?: Partial<FormData>;
|
||||
onSubmit: (data: FormData) => void;
|
||||
};
|
||||
|
||||
export const Form = (props: Props) => {
|
||||
const [ratelimit, setRatelimit] = createSignal(props.initialValues?.ratelimit ?? 0);
|
||||
const [ratelimitSubnetIpv4, setRatelimitSubnetIpv4] = createSignal(
|
||||
props.initialValues?.ratelimit_subnet_len_ipv4 ?? 0,
|
||||
);
|
||||
const [ratelimitSubnetIpv6, setRatelimitSubnetIpv6] = createSignal(
|
||||
props.initialValues?.ratelimit_subnet_len_ipv6 ?? 0,
|
||||
);
|
||||
const [ratelimitWhitelist, setRatelimitWhitelist] = createSignal(
|
||||
props.initialValues?.ratelimit_whitelist ?? '',
|
||||
);
|
||||
const [ednsCsEnabled, setEdnsCsEnabled] = createSignal(
|
||||
props.initialValues?.edns_cs_enabled ?? false,
|
||||
);
|
||||
const [ednsCsUseCustom, setEdnsCsUseCustom] = createSignal(
|
||||
props.initialValues?.edns_cs_use_custom ?? false,
|
||||
);
|
||||
const [ednsCsCustomIp, setEdnsCsCustomIp] = createSignal(
|
||||
props.initialValues?.edns_cs_custom_ip ?? '',
|
||||
);
|
||||
const [dnssecEnabled, setDnssecEnabled] = createSignal(
|
||||
props.initialValues?.dnssec_enabled ?? false,
|
||||
);
|
||||
const [disableIpv6, setDisableIpv6] = createSignal(props.initialValues?.disable_ipv6 ?? false);
|
||||
const [blockingMode, setBlockingMode] = createSignal(
|
||||
props.initialValues?.blocking_mode ?? BLOCKING_MODES.default,
|
||||
);
|
||||
const [blockingIpv4, setBlockingIpv4] = createSignal(props.initialValues?.blocking_ipv4 ?? '');
|
||||
const [blockingIpv6, setBlockingIpv6] = createSignal(props.initialValues?.blocking_ipv6 ?? '');
|
||||
const [blockedResponseTtl, setBlockedResponseTtl] = createSignal(
|
||||
props.initialValues?.blocked_response_ttl ?? 0,
|
||||
);
|
||||
|
||||
// Error signals
|
||||
const [ratelimitError, setRatelimitError] = createSignal('');
|
||||
const [subnetIpv4Error, setSubnetIpv4Error] = createSignal('');
|
||||
const [subnetIpv6Error, setSubnetIpv6Error] = createSignal('');
|
||||
const [whitelistError, setWhitelistError] = createSignal('');
|
||||
const [customIpError, setCustomIpError] = createSignal('');
|
||||
const [blockingIpv4Error, setBlockingIpv4Error] = createSignal('');
|
||||
const [blockingIpv6Error, setBlockingIpv6Error] = createSignal('');
|
||||
const [ttlError, setTtlError] = createSignal('');
|
||||
|
||||
const validateAll = () => {
|
||||
setRatelimitError(
|
||||
validateRequiredValue(String(ratelimit())) ||
|
||||
validateMaxValue(ratelimit(), RATE_LIMIT.MAX) ||
|
||||
'',
|
||||
);
|
||||
setSubnetIpv4Error(
|
||||
validateRequiredValue(String(ratelimitSubnetIpv4())) ||
|
||||
validateBetween(
|
||||
ratelimitSubnetIpv4(),
|
||||
IPV4_SUBNET_PREFIX.MIN,
|
||||
IPV4_SUBNET_PREFIX.MAX,
|
||||
) ||
|
||||
'',
|
||||
);
|
||||
setSubnetIpv6Error(
|
||||
validateRequiredValue(String(ratelimitSubnetIpv6())) ||
|
||||
validateBetween(
|
||||
ratelimitSubnetIpv6(),
|
||||
IPV6_SUBNET_PREFIX.MIN,
|
||||
IPV6_SUBNET_PREFIX.MAX,
|
||||
) ||
|
||||
'',
|
||||
);
|
||||
setWhitelistError(
|
||||
ratelimitWhitelist() ? validateIpPerLine(ratelimitWhitelist()) || '' : '',
|
||||
);
|
||||
setTtlError(
|
||||
validateRequiredValue(String(blockedResponseTtl())) ||
|
||||
validateBetween(blockedResponseTtl(), UINT32_RANGE.MIN, UINT32_RANGE.MAX) ||
|
||||
'',
|
||||
);
|
||||
|
||||
if (ednsCsUseCustom()) {
|
||||
const err =
|
||||
validateRequiredValue(ednsCsCustomIp()) || validateIp(ednsCsCustomIp()) || '';
|
||||
setCustomIpError(err);
|
||||
}
|
||||
|
||||
if (blockingMode() === BLOCKING_MODES.custom_ip) {
|
||||
setBlockingIpv4Error(
|
||||
validateRequiredValue(blockingIpv4()) || validateIpv4(blockingIpv4()) || '',
|
||||
);
|
||||
setBlockingIpv6Error(
|
||||
validateRequiredValue(blockingIpv6()) || validateIpv6(blockingIpv6()) || '',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const hasErrors = createMemo(
|
||||
() =>
|
||||
ratelimitError() ||
|
||||
subnetIpv4Error() ||
|
||||
subnetIpv6Error() ||
|
||||
whitelistError() ||
|
||||
customIpError() ||
|
||||
blockingIpv4Error() ||
|
||||
blockingIpv6Error() ||
|
||||
ttlError(),
|
||||
);
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
validateAll();
|
||||
if (hasErrors()) return;
|
||||
|
||||
props.onSubmit({
|
||||
ratelimit: ratelimit(),
|
||||
ratelimit_subnet_len_ipv4: ratelimitSubnetIpv4(),
|
||||
ratelimit_subnet_len_ipv6: ratelimitSubnetIpv6(),
|
||||
ratelimit_whitelist: ratelimitWhitelist(),
|
||||
edns_cs_enabled: ednsCsEnabled(),
|
||||
edns_cs_use_custom: ednsCsUseCustom(),
|
||||
edns_cs_custom_ip: ednsCsCustomIp(),
|
||||
dnssec_enabled: dnssecEnabled(),
|
||||
disable_ipv6: disableIpv6(),
|
||||
blocking_mode: blockingMode(),
|
||||
blocking_ipv4: blockingIpv4(),
|
||||
blocking_ipv6: blockingIpv6(),
|
||||
blocked_response_ttl: blockedResponseTtl(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} class={theme.form.form}>
|
||||
<div class={theme.form.group}>
|
||||
<div class={theme.form.input}>
|
||||
<Input
|
||||
value={String(ratelimit())}
|
||||
onChange={(e: Event) =>
|
||||
setRatelimit(Number((e.target as HTMLInputElement).value))
|
||||
}
|
||||
onBlur={() =>
|
||||
setRatelimitError(
|
||||
validateRequiredValue(String(ratelimit())) ||
|
||||
validateMaxValue(ratelimit(), RATE_LIMIT.MAX) ||
|
||||
'',
|
||||
)
|
||||
}
|
||||
data-testid="dns_config_ratelimit"
|
||||
type="number"
|
||||
label={
|
||||
<>
|
||||
{intl.getMessage('server_config_rate_limit')}
|
||||
<FaqTooltip
|
||||
text={intl.getMessage('server_config_rate_limit_faq')}
|
||||
menuSize="large"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
placeholder={intl.getMessage('server_config_rate_limit_placeholder')}
|
||||
errorMessage={ratelimitError()}
|
||||
min={UINT32_RANGE.MIN}
|
||||
max={UINT32_RANGE.MAX}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={theme.form.input}>
|
||||
<Input
|
||||
value={String(ratelimitSubnetIpv4())}
|
||||
onChange={(e: Event) =>
|
||||
setRatelimitSubnetIpv4(Number((e.target as HTMLInputElement).value))
|
||||
}
|
||||
onBlur={() =>
|
||||
setSubnetIpv4Error(
|
||||
validateRequiredValue(String(ratelimitSubnetIpv4())) ||
|
||||
validateBetween(
|
||||
ratelimitSubnetIpv4(),
|
||||
IPV4_SUBNET_PREFIX.MIN,
|
||||
IPV4_SUBNET_PREFIX.MAX,
|
||||
) ||
|
||||
'',
|
||||
)
|
||||
}
|
||||
data-testid="dns_config_subnet_ipv4"
|
||||
type="number"
|
||||
label={
|
||||
<>
|
||||
{intl.getMessage('server_config_subnet_len_ipv4')}
|
||||
<FaqTooltip
|
||||
text={intl.getMessage('server_config_subnet_len_ipv4_faq')}
|
||||
menuSize="large"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
placeholder={intl.getMessage('server_config_subnet_len_placeholder')}
|
||||
errorMessage={subnetIpv4Error()}
|
||||
min={0}
|
||||
max={32}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={theme.form.input}>
|
||||
<Input
|
||||
value={String(ratelimitSubnetIpv6())}
|
||||
onChange={(e: Event) =>
|
||||
setRatelimitSubnetIpv6(Number((e.target as HTMLInputElement).value))
|
||||
}
|
||||
onBlur={() =>
|
||||
setSubnetIpv6Error(
|
||||
validateRequiredValue(String(ratelimitSubnetIpv6())) ||
|
||||
validateBetween(
|
||||
ratelimitSubnetIpv6(),
|
||||
IPV6_SUBNET_PREFIX.MIN,
|
||||
IPV6_SUBNET_PREFIX.MAX,
|
||||
) ||
|
||||
'',
|
||||
)
|
||||
}
|
||||
data-testid="dns_config_subnet_ipv6"
|
||||
type="number"
|
||||
label={
|
||||
<>
|
||||
{intl.getMessage('server_config_subnet_len_ipv6')}
|
||||
<FaqTooltip
|
||||
text={intl.getMessage('server_config_subnet_len_ipv6_faq')}
|
||||
menuSize="large"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
placeholder={intl.getMessage('server_config_subnet_len_placeholder')}
|
||||
errorMessage={subnetIpv6Error()}
|
||||
min={0}
|
||||
max={128}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={theme.form.input}>
|
||||
<Textarea
|
||||
value={ratelimitWhitelist()}
|
||||
onChange={(e: Event) =>
|
||||
setRatelimitWhitelist((e.target as HTMLTextAreaElement).value)
|
||||
}
|
||||
onBlur={() =>
|
||||
setWhitelistError(
|
||||
ratelimitWhitelist()
|
||||
? validateIpPerLine(ratelimitWhitelist()) || ''
|
||||
: '',
|
||||
)
|
||||
}
|
||||
data-testid="dns_config_ratelimit_whitelist"
|
||||
label={
|
||||
<>
|
||||
{intl.getMessage('server_config_rate_limit_whitelist')}
|
||||
<FaqTooltip
|
||||
text={intl.getMessage('server_config_rate_limit_whitelist_faq')}
|
||||
menuSize="large"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
placeholder={intl.getMessage('ip_addresses_placeholder')}
|
||||
errorMessage={whitelistError()}
|
||||
size="medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={theme.form.input}>
|
||||
<Checkbox
|
||||
name="edns_cs_enabled"
|
||||
checked={ednsCsEnabled()}
|
||||
onChange={(e: Event) =>
|
||||
setEdnsCsEnabled((e.target as HTMLInputElement).checked)
|
||||
}
|
||||
data-testid="dns_config_edns_cs_enabled"
|
||||
verticalAlign="start"
|
||||
>
|
||||
<div>
|
||||
<div class={theme.text.t2}>
|
||||
{intl.getMessage('server_config_edns_enable')}
|
||||
</div>
|
||||
<div class={theme.text.t4}>
|
||||
{intl.getMessage('server_config_edns_cs_desc')}
|
||||
</div>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<div class={theme.form.inner}>
|
||||
<div class={theme.form.input}>
|
||||
<Checkbox
|
||||
name="edns_cs_use_custom"
|
||||
checked={ednsCsUseCustom()}
|
||||
onChange={(e: Event) =>
|
||||
setEdnsCsUseCustom((e.target as HTMLInputElement).checked)
|
||||
}
|
||||
data-testid="dns_config_edns_use_custom_ip"
|
||||
disabled={!ednsCsEnabled()}
|
||||
verticalAlign="start"
|
||||
>
|
||||
<div>
|
||||
<div class={theme.text.t2}>
|
||||
{intl.getMessage('server_config_edns_use_custom_ip')}
|
||||
</div>
|
||||
<div class={theme.text.t4}>
|
||||
{intl.getMessage('server_config_edns_use_custom_ip_desc')}
|
||||
</div>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<Show when={ednsCsUseCustom()}>
|
||||
<div class={theme.form.input}>
|
||||
<Input
|
||||
value={ednsCsCustomIp()}
|
||||
onChange={(e: Event) =>
|
||||
setEdnsCsCustomIp((e.target as HTMLInputElement).value)
|
||||
}
|
||||
onBlur={() => {
|
||||
if (ednsCsUseCustom()) {
|
||||
setCustomIpError(
|
||||
validateRequiredValue(ednsCsCustomIp()) ||
|
||||
validateIp(ednsCsCustomIp()) ||
|
||||
'',
|
||||
);
|
||||
}
|
||||
}}
|
||||
data-testid="dns_config_edns_cs_custom_ip"
|
||||
placeholder={intl.getMessage('enter_ip_address_placeholder')}
|
||||
errorMessage={customIpError()}
|
||||
disabled={!ednsCsEnabled()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<For each={checkboxes}>
|
||||
{({ name, placeholder, subtitle }) => (
|
||||
<div class={theme.form.input}>
|
||||
<Checkbox
|
||||
name={name}
|
||||
checked={
|
||||
name === 'dnssec_enabled' ? dnssecEnabled() : disableIpv6()
|
||||
}
|
||||
onChange={(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).checked;
|
||||
if (name === 'dnssec_enabled') setDnssecEnabled(v);
|
||||
else setDisableIpv6(v);
|
||||
}}
|
||||
id={`dns_config_${name}`}
|
||||
verticalAlign="start"
|
||||
>
|
||||
<div>
|
||||
<div class={theme.text.t2}>{placeholder}</div>
|
||||
<div class={theme.text.t4}>{subtitle}</div>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<div class={theme.form.input}>
|
||||
<div class={cn(s.subtitle, theme.title.h6)}>
|
||||
{intl.getMessage('server_config_blocking_mode')}
|
||||
</div>
|
||||
<div class={s.descriptions}>
|
||||
<For each={blockingModesDescriptions}>
|
||||
{(description) => <div class={theme.text.t2}>{description}</div>}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Radio
|
||||
value={blockingMode()}
|
||||
handleChange={(v: string) => setBlockingMode(v)}
|
||||
name="blocking_mode"
|
||||
options={blockingModeOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={blockingMode() === BLOCKING_MODES.custom_ip}>
|
||||
<For each={customIps}>
|
||||
{({ label, name, placeholder, faq, validateIp: validateIpFn }) => (
|
||||
<div class={theme.form.input}>
|
||||
<Input
|
||||
value={
|
||||
name === 'blocking_ipv4' ? blockingIpv4() : blockingIpv6()
|
||||
}
|
||||
onChange={(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
if (name === 'blocking_ipv4') setBlockingIpv4(v);
|
||||
else setBlockingIpv6(v);
|
||||
}}
|
||||
onBlur={() => {
|
||||
const val =
|
||||
name === 'blocking_ipv4'
|
||||
? blockingIpv4()
|
||||
: blockingIpv6();
|
||||
const err =
|
||||
validateRequiredValue(val) || validateIpFn(val) || '';
|
||||
if (name === 'blocking_ipv4') setBlockingIpv4Error(err);
|
||||
else setBlockingIpv6Error(err);
|
||||
}}
|
||||
data-testid={`dns_config_${name}`}
|
||||
type="text"
|
||||
label={
|
||||
<>
|
||||
{label}
|
||||
<FaqTooltip text={faq} menuSize="large" />
|
||||
</>
|
||||
}
|
||||
placeholder={placeholder}
|
||||
errorMessage={
|
||||
name === 'blocking_ipv4'
|
||||
? blockingIpv4Error()
|
||||
: blockingIpv6Error()
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
<div class={theme.form.input}>
|
||||
<Input
|
||||
value={String(blockedResponseTtl())}
|
||||
onChange={(e: Event) =>
|
||||
setBlockedResponseTtl(Number((e.target as HTMLInputElement).value))
|
||||
}
|
||||
onBlur={() =>
|
||||
setTtlError(validateRequiredValue(String(blockedResponseTtl())) || '')
|
||||
}
|
||||
data-testid="dns_config_blocked_response_ttl"
|
||||
type="number"
|
||||
label={
|
||||
<>
|
||||
{intl.getMessage('server_config_blocking_mode_ttl')}
|
||||
<FaqTooltip
|
||||
text={intl.getMessage('server_config_blocking_mode_ttl_faq')}
|
||||
menuSize="large"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
placeholder={intl.getMessage('form_enter_blocked_response_ttl')}
|
||||
errorMessage={ttlError()}
|
||||
min={UINT32_RANGE.MIN}
|
||||
max={UINT32_RANGE.MAX}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={theme.form.buttonGroup}>
|
||||
<Button
|
||||
type="submit"
|
||||
id="dns_config_save"
|
||||
variant="primary"
|
||||
disabled={props.processing}
|
||||
class={theme.form.button}
|
||||
size="small"
|
||||
>
|
||||
{intl.getMessage('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { Form } from './Form';
|
||||
@@ -0,0 +1,55 @@
|
||||
import { untrack, type Accessor } from 'solid-js';
|
||||
|
||||
import { dnsConfigState, setDnsConfig } from 'panel/stores/dnsConfig';
|
||||
import intl from 'panel/common/intl';
|
||||
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
|
||||
import { Textarea } from 'panel/common/controls/Textarea';
|
||||
import { validateIpPerLine } from 'panel/helpers/validators';
|
||||
import { useField } from 'panel/hooks/useField';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
type Props = {
|
||||
open: Accessor<boolean>;
|
||||
onClose: () => void;
|
||||
processing: boolean;
|
||||
};
|
||||
|
||||
export const RateLimitAllowlistDialog = (props: Props) => {
|
||||
const field = useField<string>(
|
||||
() => props.open(),
|
||||
() => dnsConfigState.ratelimit_whitelist,
|
||||
{
|
||||
validate: (v) => (v ? validateIpPerLine(v) || '' : ''),
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigDialog
|
||||
open={props.open()}
|
||||
title={intl.getMessage('dns_rate_limit_allowlist_title')}
|
||||
description={intl.getMessage('dns_rate_limit_allowlist_desc')}
|
||||
onClose={props.onClose}
|
||||
onSubmit={() => {
|
||||
field.submitIfValid((v) => {
|
||||
setDnsConfig({ ratelimit_whitelist: v });
|
||||
untrack(() => props.onClose());
|
||||
});
|
||||
}}
|
||||
processing={props.processing}
|
||||
submitDisabled={!!field.error()}
|
||||
>
|
||||
<div class={theme.form.input}>
|
||||
<Textarea
|
||||
value={field.value()}
|
||||
onChange={(e: Event) => field.setValue((e.target as HTMLTextAreaElement).value)}
|
||||
onBlur={() => field.validate()}
|
||||
id="ratelimit_whitelist"
|
||||
label={intl.getMessage('dns_rate_limit_allowlist_label')}
|
||||
placeholder={intl.getMessage('dns_rate_limit_allowlist_placeholder')}
|
||||
size="medium"
|
||||
errorMessage={field.error()}
|
||||
/>
|
||||
</div>
|
||||
</ConfigDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { untrack, type Accessor } from 'solid-js';
|
||||
|
||||
import { dnsConfigState, setDnsConfig } from 'panel/stores/dnsConfig';
|
||||
import intl from 'panel/common/intl';
|
||||
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
|
||||
import { Input } from 'panel/common/controls/Input';
|
||||
import { RATE_LIMIT } from 'panel/helpers/constants';
|
||||
import { validateRequiredValue, validateBetween } from 'panel/helpers/validators';
|
||||
import { useField } from 'panel/hooks/useField';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
type Props = {
|
||||
open: Accessor<boolean>;
|
||||
onClose: () => void;
|
||||
processing: boolean;
|
||||
};
|
||||
|
||||
export const RateLimitDialog = (props: Props) => {
|
||||
const field = useField<number>(
|
||||
() => props.open(),
|
||||
() => dnsConfigState.ratelimit,
|
||||
{
|
||||
validate: (v) =>
|
||||
validateRequiredValue(String(v)) ||
|
||||
validateBetween(v, RATE_LIMIT.MIN, RATE_LIMIT.MAX) ||
|
||||
'',
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigDialog
|
||||
open={props.open()}
|
||||
title={intl.getMessage('dns_rate_limit_title')}
|
||||
description={
|
||||
<>
|
||||
<p>{intl.getMessage('dns_rate_limit_desc')}</p>
|
||||
<p>{intl.getMessage('dns_rate_limit_desc_2')}</p>
|
||||
</>
|
||||
}
|
||||
onClose={props.onClose}
|
||||
onSubmit={() => {
|
||||
field.submitIfValid((v) => {
|
||||
setDnsConfig({ ratelimit: v });
|
||||
untrack(() => props.onClose());
|
||||
});
|
||||
}}
|
||||
processing={props.processing}
|
||||
submitDisabled={!!field.error()}
|
||||
>
|
||||
<div class={theme.form.input}>
|
||||
<Input
|
||||
type="number"
|
||||
value={field.value()}
|
||||
onChange={(e: Event) =>
|
||||
field.setValue(Number((e.target as HTMLInputElement).value))
|
||||
}
|
||||
onBlur={() => field.validate()}
|
||||
id="ratelimit"
|
||||
label={intl.getMessage('server_config_rate_limit')}
|
||||
placeholder={intl.getMessage('dns_rate_limit_placeholder')}
|
||||
min={RATE_LIMIT.MIN}
|
||||
max={RATE_LIMIT.MAX}
|
||||
errorMessage={field.error()}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
</ConfigDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { untrack, type Accessor } from 'solid-js';
|
||||
|
||||
import { dnsConfigState, setDnsConfig } from 'panel/stores/dnsConfig';
|
||||
import intl from 'panel/common/intl';
|
||||
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
|
||||
import { Input } from 'panel/common/controls/Input';
|
||||
import { IPV4_SUBNET_PREFIX, IP_VERSION } from 'panel/helpers/constants';
|
||||
import { validateRequiredValue, validateBetween } from 'panel/helpers/validators';
|
||||
import { useField } from 'panel/hooks/useField';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
type Props = {
|
||||
open: Accessor<boolean>;
|
||||
onClose: () => void;
|
||||
processing: boolean;
|
||||
};
|
||||
|
||||
export const SubnetPrefixV4Dialog = (props: Props) => {
|
||||
const field = useField<number>(
|
||||
() => props.open(),
|
||||
() => dnsConfigState.ratelimit_subnet_len_ipv4 ?? IPV4_SUBNET_PREFIX.DEFAULT,
|
||||
{
|
||||
validate: (v) =>
|
||||
validateRequiredValue(String(v)) ||
|
||||
validateBetween(v, IPV4_SUBNET_PREFIX.MIN, IPV4_SUBNET_PREFIX.MAX) ||
|
||||
'',
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigDialog
|
||||
open={props.open()}
|
||||
title={intl.getMessage('dns_subnet_prefix_title', { value: IP_VERSION.V4 })}
|
||||
description={intl.getMessage('dns_subnet_prefix_desc', { value: IP_VERSION.V4 })}
|
||||
onClose={props.onClose}
|
||||
onSubmit={() => {
|
||||
field.submitIfValid((v) => {
|
||||
setDnsConfig({ ratelimit_subnet_len_ipv4: v });
|
||||
untrack(() => props.onClose());
|
||||
});
|
||||
}}
|
||||
processing={props.processing}
|
||||
submitDisabled={!!field.error()}
|
||||
>
|
||||
<div class={theme.form.input}>
|
||||
<Input
|
||||
type="number"
|
||||
value={field.value()}
|
||||
onChange={(e: Event) =>
|
||||
field.setValue(Number((e.target as HTMLInputElement).value))
|
||||
}
|
||||
onBlur={() => field.validate()}
|
||||
id="ratelimit_subnet_len_ipv4"
|
||||
label={intl.getMessage('dns_subnet_prefix_title', { value: IP_VERSION.V4 })}
|
||||
placeholder={intl.getMessage('dns_subnet_placeholder')}
|
||||
min={IPV4_SUBNET_PREFIX.MIN}
|
||||
max={IPV4_SUBNET_PREFIX.MAX}
|
||||
errorMessage={field.error()}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
</ConfigDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { untrack, type Accessor } from 'solid-js';
|
||||
|
||||
import { dnsConfigState, setDnsConfig } from 'panel/stores/dnsConfig';
|
||||
import intl from 'panel/common/intl';
|
||||
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
|
||||
import { Input } from 'panel/common/controls/Input';
|
||||
import { IPV6_SUBNET_PREFIX, IP_VERSION } from 'panel/helpers/constants';
|
||||
import { validateRequiredValue, validateBetween } from 'panel/helpers/validators';
|
||||
import { useField } from 'panel/hooks/useField';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
type Props = {
|
||||
open: Accessor<boolean>;
|
||||
onClose: () => void;
|
||||
processing: boolean;
|
||||
};
|
||||
|
||||
export const SubnetPrefixV6Dialog = (props: Props) => {
|
||||
const field = useField<number>(
|
||||
() => props.open(),
|
||||
() => dnsConfigState.ratelimit_subnet_len_ipv6 ?? IPV6_SUBNET_PREFIX.DEFAULT,
|
||||
{
|
||||
validate: (v) =>
|
||||
validateRequiredValue(String(v)) ||
|
||||
validateBetween(v, IPV6_SUBNET_PREFIX.MIN, IPV6_SUBNET_PREFIX.MAX) ||
|
||||
'',
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigDialog
|
||||
open={props.open()}
|
||||
title={intl.getMessage('dns_subnet_prefix_title', { value: IP_VERSION.V6 })}
|
||||
description={intl.getMessage('dns_subnet_prefix_desc', { value: IP_VERSION.V6 })}
|
||||
onClose={props.onClose}
|
||||
onSubmit={() => {
|
||||
field.submitIfValid((v) => {
|
||||
setDnsConfig({ ratelimit_subnet_len_ipv6: v });
|
||||
untrack(() => props.onClose());
|
||||
});
|
||||
}}
|
||||
processing={props.processing}
|
||||
submitDisabled={!!field.error()}
|
||||
>
|
||||
<div class={theme.form.input}>
|
||||
<Input
|
||||
type="number"
|
||||
value={field.value()}
|
||||
onChange={(e: Event) =>
|
||||
field.setValue(Number((e.target as HTMLInputElement).value))
|
||||
}
|
||||
onBlur={() => field.validate()}
|
||||
id="ratelimit_subnet_len_ipv6"
|
||||
label={intl.getMessage('dns_subnet_prefix_title', { value: IP_VERSION.V6 })}
|
||||
placeholder={intl.getMessage('dns_subnet_placeholder')}
|
||||
min={IPV6_SUBNET_PREFIX.MIN}
|
||||
max={IPV6_SUBNET_PREFIX.MAX}
|
||||
errorMessage={field.error()}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
</ConfigDialog>
|
||||
);
|
||||
};
|
||||
@@ -1,58 +1,47 @@
|
||||
import { createMemo } from 'solid-js';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import cn from 'clsx';
|
||||
|
||||
import intl from 'panel/common/intl';
|
||||
import { dnsConfigState, setDnsConfig } from 'panel/stores/dnsConfig';
|
||||
import { dnsConfigState, togglePrivatePtrResolvers } from 'panel/stores/dnsConfig';
|
||||
import { settingsState, testUpstreamWithFormValues } from 'panel/stores/settings';
|
||||
import { Button } from 'panel/common/ui/Button';
|
||||
import { SettingRow } from 'panel/common/ui/SettingRow';
|
||||
import { useDialogOpen } from 'panel/hooks/useField';
|
||||
import { getUpstreamModeSummary, getUpstreamServersSummary, getTtlSummary } from '../helpers';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
import { Form } from './blocks/Form';
|
||||
|
||||
type UpstreamFormData = {
|
||||
upstream_dns: string;
|
||||
upstream_mode: string;
|
||||
fallback_dns: string;
|
||||
bootstrap_dns: string;
|
||||
local_ptr_upstreams: string;
|
||||
use_private_ptr_resolvers: boolean;
|
||||
resolve_clients: boolean;
|
||||
upstream_timeout: number;
|
||||
};
|
||||
import { UpstreamModeDialog } from './blocks/UpstreamModeDialog';
|
||||
import { ServerAddressesDialog } from './blocks/ServerAddressesDialog';
|
||||
import { FallbackDnsDialog } from './blocks/FallbackDnsDialog';
|
||||
import { BootstrapDnsDialog } from './blocks/BootstrapDnsDialog';
|
||||
import { TimeoutDialog } from './blocks/TimeoutDialog';
|
||||
import { Paths } from 'panel/components/Routes/Paths';
|
||||
|
||||
export const Upstream = () => {
|
||||
const handleSubmit = (values: UpstreamFormData) => {
|
||||
const {
|
||||
fallback_dns,
|
||||
bootstrap_dns,
|
||||
upstream_dns,
|
||||
upstream_mode,
|
||||
resolve_clients,
|
||||
local_ptr_upstreams,
|
||||
use_private_ptr_resolvers,
|
||||
upstream_timeout,
|
||||
} = values;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const upstreamDnsFile = dnsConfigState.upstream_dns_file;
|
||||
const dnsConfig = {
|
||||
fallback_dns,
|
||||
bootstrap_dns,
|
||||
upstream_mode,
|
||||
resolve_clients,
|
||||
local_ptr_upstreams,
|
||||
use_private_ptr_resolvers,
|
||||
upstream_timeout,
|
||||
...(upstreamDnsFile ? null : { upstream_dns }),
|
||||
};
|
||||
const upstreamModeDialog = useDialogOpen();
|
||||
const serverAddressesDialog = useDialogOpen();
|
||||
const fallbackDialog = useDialogOpen();
|
||||
const bootstrapDialog = useDialogOpen();
|
||||
const timeoutDialog = useDialogOpen();
|
||||
|
||||
setDnsConfig(dnsConfig);
|
||||
};
|
||||
|
||||
const upstreamDns = createMemo(() =>
|
||||
dnsConfigState.upstream_dns_file
|
||||
? intl.getMessage('upstream_dns_configured_in_file', {
|
||||
path: dnsConfigState.upstream_dns_file,
|
||||
})
|
||||
: dnsConfigState.upstream_dns,
|
||||
const upstreamModeValue = createMemo(() =>
|
||||
getUpstreamModeSummary(dnsConfigState.upstream_mode),
|
||||
);
|
||||
const serverAddressesValue = createMemo(() =>
|
||||
getUpstreamServersSummary(dnsConfigState.upstream_dns, dnsConfigState.upstream_dns_file),
|
||||
);
|
||||
const fallbackServersValue = createMemo(() =>
|
||||
getUpstreamServersSummary(dnsConfigState.fallback_dns),
|
||||
);
|
||||
const bootstrapServersValue = createMemo(() =>
|
||||
getUpstreamServersSummary(dnsConfigState.bootstrap_dns),
|
||||
);
|
||||
const timeoutValue = createMemo(() => getTtlSummary(dnsConfigState.upstream_timeout));
|
||||
|
||||
const processing = () => dnsConfigState.processingSetConfig;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -60,18 +49,113 @@ export const Upstream = () => {
|
||||
{intl.getMessage('upstream_dns')}
|
||||
</h2>
|
||||
|
||||
<Form
|
||||
initialValues={{
|
||||
upstream_dns: upstreamDns(),
|
||||
fallback_dns: dnsConfigState.fallback_dns,
|
||||
bootstrap_dns: dnsConfigState.bootstrap_dns,
|
||||
upstream_mode: dnsConfigState.upstream_mode,
|
||||
resolve_clients: dnsConfigState.resolve_clients,
|
||||
local_ptr_upstreams: dnsConfigState.local_ptr_upstreams,
|
||||
use_private_ptr_resolvers: dnsConfigState.use_private_ptr_resolvers,
|
||||
upstream_timeout: dnsConfigState.upstream_timeout,
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
<SettingRow
|
||||
variant="link"
|
||||
id="upstream_mode"
|
||||
title={intl.getMessage('dns_upstream_mode')}
|
||||
description={intl.getMessage('dns_upstream_mode_desc')}
|
||||
value={upstreamModeValue()}
|
||||
onClick={upstreamModeDialog.openDialog}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
variant="link"
|
||||
id="server_addresses"
|
||||
title={intl.getMessage('dns_server_addresses')}
|
||||
description={intl.getMessage('dns_server_addresses_desc')}
|
||||
value={serverAddressesValue()}
|
||||
onClick={serverAddressesDialog.openDialog}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
variant="link"
|
||||
id="fallback_servers"
|
||||
title={intl.getMessage('dns_fallback_servers')}
|
||||
description={intl.getMessage('dns_fallback_dns_desc')}
|
||||
value={fallbackServersValue()}
|
||||
onClick={fallbackDialog.openDialog}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
variant="link"
|
||||
id="bootstrap_servers"
|
||||
title={intl.getMessage('dns_bootstrap_servers')}
|
||||
description={intl.getMessage('dns_bootstrap_dns_desc')}
|
||||
value={bootstrapServersValue()}
|
||||
onClick={bootstrapDialog.openDialog}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
variant="link"
|
||||
id="upstream_timeout"
|
||||
title={intl.getMessage('dns_upstream_timeout')}
|
||||
description={intl.getMessage('dns_upstream_timeout_desc')}
|
||||
value={timeoutValue()}
|
||||
onClick={timeoutDialog.openDialog}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
variant="switch-link"
|
||||
id="private_reverse"
|
||||
title={intl.getMessage('dns_private_reverse_resolvers')}
|
||||
description={
|
||||
<>
|
||||
<p>{intl.getMessage('dns_private_reverse_resolvers_desc')}</p>
|
||||
<p>{intl.getMessage('dns_private_reverse_resolvers_disabled_desc')}</p>
|
||||
</>
|
||||
}
|
||||
checked={dnsConfigState.use_private_ptr_resolvers}
|
||||
onChange={() => togglePrivatePtrResolvers()}
|
||||
onClick={() => navigate(Paths.DnsPrivateReverse)}
|
||||
divider
|
||||
/>
|
||||
|
||||
<div class={theme.form.actionRow}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={settingsState.processingTestUpstream}
|
||||
onClick={() =>
|
||||
testUpstreamWithFormValues(
|
||||
{
|
||||
bootstrap_dns: dnsConfigState.bootstrap_dns,
|
||||
upstream_dns: dnsConfigState.upstream_dns,
|
||||
local_ptr_upstreams: dnsConfigState.local_ptr_upstreams,
|
||||
fallback_dns: dnsConfigState.fallback_dns,
|
||||
},
|
||||
dnsConfigState.upstream_dns_file,
|
||||
)
|
||||
}
|
||||
class={theme.form.actionButton}
|
||||
compact
|
||||
>
|
||||
{intl.getMessage('dns_test_upstreams')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<UpstreamModeDialog
|
||||
open={upstreamModeDialog.open}
|
||||
onClose={upstreamModeDialog.closeDialog}
|
||||
processing={processing()}
|
||||
/>
|
||||
<ServerAddressesDialog
|
||||
open={serverAddressesDialog.open}
|
||||
onClose={serverAddressesDialog.closeDialog}
|
||||
processing={processing()}
|
||||
/>
|
||||
<FallbackDnsDialog
|
||||
open={fallbackDialog.open}
|
||||
onClose={fallbackDialog.closeDialog}
|
||||
processing={processing()}
|
||||
/>
|
||||
<BootstrapDnsDialog
|
||||
open={bootstrapDialog.open}
|
||||
onClose={bootstrapDialog.closeDialog}
|
||||
processing={processing()}
|
||||
/>
|
||||
<TimeoutDialog
|
||||
open={timeoutDialog.open}
|
||||
onClose={timeoutDialog.closeDialog}
|
||||
processing={processing()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { untrack, type Accessor } from 'solid-js';
|
||||
import { dnsConfigState, setDnsConfig } from 'panel/stores/dnsConfig';
|
||||
import intl from 'panel/common/intl';
|
||||
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
|
||||
import { Textarea } from 'panel/common/controls/Textarea';
|
||||
import { validateUpstreams } from 'panel/helpers/validators';
|
||||
import { useField } from 'panel/hooks/useField';
|
||||
import { Examples } from './Examples';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
type Props = {
|
||||
open: Accessor<boolean>;
|
||||
onClose: () => void;
|
||||
processing: boolean;
|
||||
};
|
||||
|
||||
export const BootstrapDnsDialog = (props: Props) => {
|
||||
const field = useField<string>(
|
||||
() => props.open(),
|
||||
() => dnsConfigState.bootstrap_dns,
|
||||
{ validate: (v) => (v ? validateUpstreams(v) || '' : '') },
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigDialog
|
||||
open={props.open()}
|
||||
title={intl.getMessage('dns_bootstrap_dns_title')}
|
||||
description={
|
||||
<>
|
||||
<p>{intl.getMessage('dns_bootstrap_dns_desc')}</p>
|
||||
<p>{intl.getMessage('dns_bootstrap_dns_desc_2')}</p>
|
||||
</>
|
||||
}
|
||||
onClose={props.onClose}
|
||||
onSubmit={() => {
|
||||
field.submitIfValid((v) => {
|
||||
setDnsConfig({ bootstrap_dns: v });
|
||||
untrack(() => props.onClose());
|
||||
});
|
||||
}}
|
||||
processing={props.processing}
|
||||
submitDisabled={!!field.error()}
|
||||
>
|
||||
<div class={theme.form.input}>
|
||||
<Textarea
|
||||
value={field.value()}
|
||||
onChange={(e: Event) => field.setValue((e.target as HTMLTextAreaElement).value)}
|
||||
onBlur={() => field.validate()}
|
||||
id="bootstrap_dns"
|
||||
label={intl.getMessage('dns_bootstrap_dns_label')}
|
||||
placeholder={intl.getMessage('dns_bootstrap_dns_placeholder')}
|
||||
errorMessage={field.error()}
|
||||
size="medium"
|
||||
/>
|
||||
</div>
|
||||
<Examples />
|
||||
</ConfigDialog>
|
||||
);
|
||||
};
|
||||
@@ -28,13 +28,13 @@
|
||||
|
||||
.listItem {
|
||||
position: relative;
|
||||
padding: 8px 16px 8px 44px;
|
||||
padding: 8px 16px 8px 24px;
|
||||
line-height: var(--lh-t1-normal);
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 16px;
|
||||
left: 0;
|
||||
color: var(--default-black-icons);
|
||||
}
|
||||
|
||||
@@ -9,140 +9,142 @@ import theme from 'panel/lib/theme';
|
||||
import { Icon } from 'panel/common/ui/Icon';
|
||||
import s from './Examples.module.pcss';
|
||||
|
||||
const examples = [
|
||||
intl.getMessage('upstream_example_udp', {
|
||||
ipv4: '94.140.14.140',
|
||||
ipv6: '2a10:50c0::1:ff',
|
||||
}),
|
||||
intl.getMessage('upstream_example_udp_port', {
|
||||
ipv4: '94.140.14.140:53',
|
||||
ipv6: '[2a10:50c0::1:ff]:53',
|
||||
}),
|
||||
intl.getMessage('upstream_example_udp_hostname', {
|
||||
value: 'udp://unfiltered.adguard-dns.com',
|
||||
}),
|
||||
intl.getMessage('upstream_example_tcp', {
|
||||
ipv4: 'tcp://94.140.14.140',
|
||||
ipv6: 'tcp://[2a10:50c0::1:ff]',
|
||||
}),
|
||||
intl.getMessage('upstream_example_tcp_port', {
|
||||
ipv4: 'tcp://94.140.14.140:53',
|
||||
ipv6: 'tcp://[2a10:50c0::1:ff]:53',
|
||||
}),
|
||||
intl.getMessage('upstream_example_tcp_hostname', {
|
||||
value: 'tcp://unfiltered.adguard-dns.com',
|
||||
}),
|
||||
intl.getMessage('upstream_example_upstream_dot', {
|
||||
value: 'tls://unfiltered.adguard-dns.com',
|
||||
a: (text: string) => (
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/DNS_over_TLS"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
}),
|
||||
intl.getMessage('upstream_example_upstream_doh', {
|
||||
value: 'https://unfiltered.adguard-dns.com/dns-query',
|
||||
a: (text: string) => (
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/DNS_over_HTTPS"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
}),
|
||||
intl.getMessage('upstream_example_upstream_doh3', {
|
||||
value: 'h3://unfiltered.adguard-dns.com/dns-query',
|
||||
a: (text: string) => (
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/HTTP/3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
}),
|
||||
intl.getMessage('upstream_example_upstream_doq', {
|
||||
value: 'quic://unfiltered.adguard-dns.com',
|
||||
a: (text: string) => (
|
||||
<a
|
||||
href="https://datatracker.ietf.org/doc/html/rfc9250"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
}),
|
||||
intl.getMessage('upstream_example_upstream_sdns', {
|
||||
value: 'sdns://...',
|
||||
a: (text: string) => (
|
||||
<a href="https://dnscrypt.info/stamps/" target="_blank" rel="noopener noreferrer">
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
b: (text: string) => (
|
||||
<a href="https://dnscrypt.info/" target="_blank" rel="noopener noreferrer">
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
c: (text: string) => (
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/DNS_over_HTTPS"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
}),
|
||||
intl.getMessage('upstream_example_upstream_reserved', {
|
||||
value: '[/example.local/]94.140.14.140',
|
||||
a: (text: string) => (
|
||||
<a
|
||||
href="https://github.com/AdguardTeam/AdGuardHome/wiki/Configuration#upstreams-for-domains"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
}),
|
||||
intl.getMessage('upstream_example_multiple_upstreams_reserved', {
|
||||
value: '[/example.local/]94.140.14.140 2a10:50c0::1:ff',
|
||||
a: (text: string) => (
|
||||
<a
|
||||
href="https://github.com/AdguardTeam/AdGuardHome/wiki/Configuration#upstreams-for-domains"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
}),
|
||||
intl.getMessage('upstream_example_upstream_comment', {
|
||||
value: `${COMMENT_LINE_DEFAULT_TOKEN} comment`,
|
||||
}),
|
||||
];
|
||||
export const Examples = () => {
|
||||
const examples = [
|
||||
intl.getMessage('upstream_example_udp', {
|
||||
ipv4: '94.140.14.140',
|
||||
ipv6: '2a10:50c0::1:ff',
|
||||
}),
|
||||
intl.getMessage('upstream_example_udp_port', {
|
||||
ipv4: '94.140.14.140:53',
|
||||
ipv6: '[2a10:50c0::1:ff]:53',
|
||||
}),
|
||||
intl.getMessage('upstream_example_udp_hostname', {
|
||||
value: 'udp://unfiltered.adguard-dns.com',
|
||||
}),
|
||||
intl.getMessage('upstream_example_tcp', {
|
||||
ipv4: 'tcp://94.140.14.140',
|
||||
ipv6: 'tcp://[2a10:50c0::1:ff]',
|
||||
}),
|
||||
intl.getMessage('upstream_example_tcp_port', {
|
||||
ipv4: 'tcp://94.140.14.140:53',
|
||||
ipv6: 'tcp://[2a10:50c0::1:ff]:53',
|
||||
}),
|
||||
intl.getMessage('upstream_example_tcp_hostname', {
|
||||
value: 'tcp://unfiltered.adguard-dns.com',
|
||||
}),
|
||||
intl.getMessage('upstream_example_upstream_dot', {
|
||||
value: 'tls://unfiltered.adguard-dns.com',
|
||||
a: (text: string) => (
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/DNS_over_TLS"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
}),
|
||||
intl.getMessage('upstream_example_upstream_doh', {
|
||||
value: 'https://unfiltered.adguard-dns.com/dns-query',
|
||||
a: (text: string) => (
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/DNS_over_HTTPS"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
}),
|
||||
intl.getMessage('upstream_example_upstream_doh3', {
|
||||
value: 'h3://unfiltered.adguard-dns.com/dns-query',
|
||||
a: (text: string) => (
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/HTTP/3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
}),
|
||||
intl.getMessage('upstream_example_upstream_doq', {
|
||||
value: 'quic://unfiltered.adguard-dns.com',
|
||||
a: (text: string) => (
|
||||
<a
|
||||
href="https://datatracker.ietf.org/doc/html/rfc9250"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
}),
|
||||
intl.getMessage('upstream_example_upstream_sdns', {
|
||||
value: 'sdns://...',
|
||||
a: (text: string) => (
|
||||
<a href="https://dnscrypt.info/stamps/" target="_blank" rel="noopener noreferrer">
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
b: (text: string) => (
|
||||
<a href="https://dnscrypt.info/" target="_blank" rel="noopener noreferrer">
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
c: (text: string) => (
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/DNS_over_HTTPS"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
}),
|
||||
intl.getMessage('upstream_example_upstream_reserved', {
|
||||
value: '[/example.local/]94.140.14.140',
|
||||
a: (text: string) => (
|
||||
<a
|
||||
href="https://github.com/AdguardTeam/AdGuardHome/wiki/Configuration#upstreams-for-domains"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
}),
|
||||
intl.getMessage('upstream_example_multiple_upstreams_reserved', {
|
||||
value: '[/example.local/]94.140.14.140 2a10:50c0::1:ff',
|
||||
a: (text: string) => (
|
||||
<a
|
||||
href="https://github.com/AdguardTeam/AdGuardHome/wiki/Configuration#upstreams-for-domains"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
}),
|
||||
intl.getMessage('upstream_example_upstream_comment', {
|
||||
value: `${COMMENT_LINE_DEFAULT_TOKEN} comment`,
|
||||
}),
|
||||
];
|
||||
|
||||
export const Examples = () => (
|
||||
<Accordion title={intl.getMessage('upstream_examples_title')} defaultOpen>
|
||||
<div class={s.list}>
|
||||
<For each={examples}>
|
||||
{(example) => (
|
||||
<div class={cn(theme.text.t3, s.listItem)}>
|
||||
<Icon icon="label" class={s.icon} />
|
||||
return (
|
||||
<Accordion title={intl.getMessage('upstream_examples_title')} compact>
|
||||
<div class={s.list}>
|
||||
<For each={examples}>
|
||||
{(example) => (
|
||||
<div class={cn(theme.text.t3, s.listItem)}>
|
||||
<Icon icon="label" class={s.icon} />
|
||||
|
||||
{example}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Accordion>
|
||||
);
|
||||
{example}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { untrack, type Accessor } from 'solid-js';
|
||||
import { dnsConfigState, setDnsConfig } from 'panel/stores/dnsConfig';
|
||||
import intl from 'panel/common/intl';
|
||||
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
|
||||
import { Textarea } from 'panel/common/controls/Textarea';
|
||||
import { validateUpstreams } from 'panel/helpers/validators';
|
||||
import { useField } from 'panel/hooks/useField';
|
||||
import { Examples } from './Examples';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
type Props = {
|
||||
open: Accessor<boolean>;
|
||||
onClose: () => void;
|
||||
processing: boolean;
|
||||
};
|
||||
|
||||
export const FallbackDnsDialog = (props: Props) => {
|
||||
const field = useField<string>(
|
||||
() => props.open(),
|
||||
() => dnsConfigState.fallback_dns,
|
||||
{ validate: (v) => (v ? validateUpstreams(v) || '' : '') },
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigDialog
|
||||
open={props.open()}
|
||||
title={intl.getMessage('dns_fallback_dns_title')}
|
||||
description={
|
||||
<>
|
||||
<p>{intl.getMessage('dns_fallback_dns_desc')}</p>
|
||||
<p>
|
||||
{intl.getMessage('dns_fallback_dns_desc_2', {
|
||||
a: (text: string) => (
|
||||
<a
|
||||
href="https://github.com/AdguardTeam/AdGuardHome/wiki/Configuration#upstreams"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class={theme.link.link}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
onClose={props.onClose}
|
||||
onSubmit={() => {
|
||||
field.submitIfValid((v) => {
|
||||
setDnsConfig({ fallback_dns: v });
|
||||
untrack(() => props.onClose());
|
||||
});
|
||||
}}
|
||||
processing={props.processing}
|
||||
submitDisabled={!!field.error()}
|
||||
>
|
||||
<div class={theme.form.input}>
|
||||
<Textarea
|
||||
value={field.value()}
|
||||
onChange={(e: Event) => field.setValue((e.target as HTMLTextAreaElement).value)}
|
||||
onBlur={() => field.validate()}
|
||||
id="fallback_dns"
|
||||
label={intl.getMessage('dns_fallback_dns_label')}
|
||||
placeholder={intl.getMessage('dns_fallback_dns_placeholder')}
|
||||
errorMessage={field.error()}
|
||||
size="medium"
|
||||
/>
|
||||
</div>
|
||||
<Examples />
|
||||
</ConfigDialog>
|
||||
);
|
||||
};
|
||||
@@ -1,418 +0,0 @@
|
||||
import { createSignal, createMemo } from 'solid-js';
|
||||
import cn from 'clsx';
|
||||
|
||||
import { settingsState, testUpstreamWithFormValues } from 'panel/stores/settings';
|
||||
import { dnsConfigState } from 'panel/stores/dnsConfig';
|
||||
import { Textarea } from 'panel/common/controls/Textarea';
|
||||
import { Input } from 'panel/common/controls/Input';
|
||||
import { Radio } from 'panel/common/controls/Radio';
|
||||
import { Checkbox } from 'panel/common/controls/Checkbox';
|
||||
import { Button } from 'panel/common/ui/Button';
|
||||
import { FaqTooltip } from 'panel/common/ui/FaqTooltip';
|
||||
import intl from 'panel/common/intl';
|
||||
import { DNS_REQUEST_OPTIONS, UINT32_RANGE, UPSTREAM_TIMEOUT } from 'panel/helpers/constants';
|
||||
import { validateMinValue, validateMaxValue, validateUpstreams } from 'panel/helpers/validators';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
import { Examples } from '../Examples';
|
||||
|
||||
import s from './Form.module.pcss';
|
||||
|
||||
type FormData = {
|
||||
upstream_dns: string;
|
||||
upstream_mode: string;
|
||||
fallback_dns: string;
|
||||
bootstrap_dns: string;
|
||||
local_ptr_upstreams: string;
|
||||
use_private_ptr_resolvers: boolean;
|
||||
resolve_clients: boolean;
|
||||
upstream_timeout: number;
|
||||
};
|
||||
|
||||
type FormProps = {
|
||||
initialValues?: Partial<FormData>;
|
||||
onSubmit: (data: FormData) => void;
|
||||
};
|
||||
|
||||
export const Form = (props: FormProps) => {
|
||||
const [upstreamDns, setUpstreamDns] = createSignal(props.initialValues?.upstream_dns || '');
|
||||
const [upstreamMode, setUpstreamMode] = createSignal(
|
||||
props.initialValues?.upstream_mode || DNS_REQUEST_OPTIONS.LOAD_BALANCING,
|
||||
);
|
||||
const [fallbackDns, setFallbackDns] = createSignal(props.initialValues?.fallback_dns || '');
|
||||
const [bootstrapDns, setBootstrapDns] = createSignal(props.initialValues?.bootstrap_dns || '');
|
||||
const [localPtrUpstreams, setLocalPtrUpstreams] = createSignal(
|
||||
props.initialValues?.local_ptr_upstreams || '',
|
||||
);
|
||||
const [usePrivatePtrResolvers, setUsePrivatePtrResolvers] = createSignal(
|
||||
props.initialValues?.use_private_ptr_resolvers || false,
|
||||
);
|
||||
const [resolveClients, setResolveClients] = createSignal(
|
||||
props.initialValues?.resolve_clients || false,
|
||||
);
|
||||
const [upstreamTimeout, setUpstreamTimeout] = createSignal(
|
||||
props.initialValues?.upstream_timeout || 0,
|
||||
);
|
||||
|
||||
const [upstreamDnsError, setUpstreamDnsError] = createSignal('');
|
||||
const [fallbackDnsError, setFallbackDnsError] = createSignal('');
|
||||
const [bootstrapDnsError, setBootstrapDnsError] = createSignal('');
|
||||
const [localPtrError, setLocalPtrError] = createSignal('');
|
||||
const [upstreamTimeoutError, setUpstreamTimeoutError] = createSignal('');
|
||||
|
||||
const upstreamModeOptions = createMemo(() => [
|
||||
{
|
||||
text: intl.getMessage('upstream_dns_load_balancing'),
|
||||
value: DNS_REQUEST_OPTIONS.LOAD_BALANCING,
|
||||
description: intl.getMessage('upstream_dns_load_balancing_desc'),
|
||||
},
|
||||
{
|
||||
text: intl.getMessage('upstream_dns_parallel_requests'),
|
||||
value: DNS_REQUEST_OPTIONS.PARALLEL,
|
||||
description: intl.getMessage('upstream_dns_parallel_requests_desc'),
|
||||
},
|
||||
{
|
||||
text: intl.getMessage('upstream_dns_fastest_addr'),
|
||||
value: DNS_REQUEST_OPTIONS.FASTEST_ADDR,
|
||||
description: (
|
||||
<>
|
||||
{intl.getMessage('upstream_dns_fastest_addr_desc')}
|
||||
<div class={cn(theme.text.t2, s.warning)}>
|
||||
{intl.getMessage('upstream_dns_fastest_addr_warning')}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
const handleUpstreamTest = () => {
|
||||
testUpstreamWithFormValues(
|
||||
{
|
||||
bootstrap_dns: bootstrapDns(),
|
||||
upstream_dns: upstreamDns(),
|
||||
local_ptr_upstreams: localPtrUpstreams(),
|
||||
fallback_dns: fallbackDns(),
|
||||
},
|
||||
dnsConfigState.upstream_dns_file,
|
||||
);
|
||||
};
|
||||
|
||||
const isSavingDisabled = createMemo(
|
||||
() => settingsState.processingTestUpstream || dnsConfigState.processingSetConfig,
|
||||
);
|
||||
|
||||
const isTestDisabled = createMemo(() => !upstreamDns() || settingsState.processingTestUpstream);
|
||||
|
||||
const validateUpstreamDns = () => {
|
||||
const err = upstreamDns() ? validateUpstreams(upstreamDns()) : undefined;
|
||||
setUpstreamDnsError(err || '');
|
||||
};
|
||||
|
||||
const validateFallbackDns = () => {
|
||||
const err = fallbackDns() ? validateUpstreams(fallbackDns()) : undefined;
|
||||
setFallbackDnsError(err || '');
|
||||
};
|
||||
|
||||
const validateBootstrapDns = () => {
|
||||
const err = bootstrapDns() ? validateUpstreams(bootstrapDns()) : undefined;
|
||||
setBootstrapDnsError(err || '');
|
||||
};
|
||||
|
||||
const validateLocalPtr = () => {
|
||||
const err = localPtrUpstreams() ? validateUpstreams(localPtrUpstreams()) : undefined;
|
||||
setLocalPtrError(err || '');
|
||||
};
|
||||
|
||||
const validateUpstreamTimeout = () => {
|
||||
const err =
|
||||
validateMinValue(upstreamTimeout(), UPSTREAM_TIMEOUT.MIN) ||
|
||||
validateMaxValue(upstreamTimeout(), UPSTREAM_TIMEOUT.MAX);
|
||||
setUpstreamTimeoutError(err || '');
|
||||
};
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
validateUpstreamDns();
|
||||
validateFallbackDns();
|
||||
validateBootstrapDns();
|
||||
validateLocalPtr();
|
||||
validateUpstreamTimeout();
|
||||
|
||||
if (
|
||||
upstreamDnsError() ||
|
||||
fallbackDnsError() ||
|
||||
bootstrapDnsError() ||
|
||||
localPtrError() ||
|
||||
upstreamTimeoutError()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.onSubmit({
|
||||
upstream_dns: upstreamDns(),
|
||||
upstream_mode: upstreamMode(),
|
||||
fallback_dns: fallbackDns(),
|
||||
bootstrap_dns: bootstrapDns(),
|
||||
local_ptr_upstreams: localPtrUpstreams(),
|
||||
use_private_ptr_resolvers: usePrivatePtrResolvers(),
|
||||
resolve_clients: resolveClients(),
|
||||
upstream_timeout: upstreamTimeout(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div class={theme.form.group}>
|
||||
<div class={theme.form.input}>
|
||||
<Textarea
|
||||
value={upstreamDns()}
|
||||
onChange={(e: Event) =>
|
||||
setUpstreamDns((e.target as HTMLTextAreaElement).value)
|
||||
}
|
||||
onBlur={validateUpstreamDns}
|
||||
id="upstream_dns"
|
||||
label={
|
||||
<>
|
||||
{intl.getMessage('upstream_dns_addresses')}
|
||||
<FaqTooltip
|
||||
text={intl.getMessage('upstream_dns_addresses_faq', {
|
||||
a: (text: string) => (
|
||||
<a
|
||||
href="https://github.com/AdguardTeam/AdGuardHome/wiki/Configuration#upstreams"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class={theme.link.link}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
b: (text: string) => (
|
||||
<a
|
||||
href="https://link.adtidy.org/forward.html?action=dns_kb_providers&from=ui&app=home"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class={theme.link.link}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
menuSize="large"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
placeholder={intl.getMessage('upstream_dns_placeholder')}
|
||||
disabled={
|
||||
!!dnsConfigState.upstream_dns_file ||
|
||||
settingsState.processingTestUpstream
|
||||
}
|
||||
size="medium"
|
||||
errorMessage={upstreamDnsError()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={theme.form.input}>
|
||||
<Radio
|
||||
name="upstream_mode"
|
||||
value={upstreamMode()}
|
||||
handleChange={(v: string) => setUpstreamMode(v)}
|
||||
options={upstreamModeOptions()}
|
||||
disabled={settingsState.processingTestUpstream}
|
||||
verticalAlign="start"
|
||||
textClass={s.radioText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Examples />
|
||||
|
||||
<div class={theme.form.group}>
|
||||
<div class={theme.form.input}>
|
||||
<Textarea
|
||||
value={fallbackDns()}
|
||||
onChange={(e: Event) =>
|
||||
setFallbackDns((e.target as HTMLTextAreaElement).value)
|
||||
}
|
||||
onBlur={validateFallbackDns}
|
||||
id="fallback_dns"
|
||||
label={
|
||||
<>
|
||||
{intl.getMessage('upstream_fallback_title')}
|
||||
<FaqTooltip
|
||||
text={intl.getMessage('upstream_fallback_title_faq')}
|
||||
menuSize="large"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
placeholder={intl.getMessage('ip_addresses_placeholder')}
|
||||
size="medium"
|
||||
errorMessage={fallbackDnsError()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={theme.form.input}>
|
||||
<Textarea
|
||||
value={bootstrapDns()}
|
||||
onChange={(e: Event) =>
|
||||
setBootstrapDns((e.target as HTMLTextAreaElement).value)
|
||||
}
|
||||
onBlur={validateBootstrapDns}
|
||||
id="bootstrap_dns"
|
||||
data-testid="bootstrap_dns"
|
||||
label={
|
||||
<>
|
||||
{intl.getMessage('upstream_bootstrap_dns_title')}
|
||||
<FaqTooltip
|
||||
text={intl.getMessage('upstream_bootstrap_dns_faq')}
|
||||
menuSize="large"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
placeholder={intl.getMessage('ip_addresses_placeholder')}
|
||||
size="medium"
|
||||
errorMessage={bootstrapDnsError()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={theme.form.input}>
|
||||
<Textarea
|
||||
value={localPtrUpstreams()}
|
||||
onChange={(e: Event) =>
|
||||
setLocalPtrUpstreams((e.target as HTMLTextAreaElement).value)
|
||||
}
|
||||
onBlur={validateLocalPtr}
|
||||
id="local_ptr_upstreams"
|
||||
data-testid="local_ptr_upstreams"
|
||||
label={
|
||||
<>
|
||||
{intl.getMessage('upstream_ptr')}
|
||||
<FaqTooltip
|
||||
text={
|
||||
<>
|
||||
<div>
|
||||
{intl.getMessage('upstream_ptr_faq_1', {
|
||||
value: '192.168.1.1/24',
|
||||
})}
|
||||
</div>
|
||||
<div>{intl.getMessage('upstream_ptr_faq_2')}</div>
|
||||
{dnsConfigState.default_local_ptr_upstreams?.length >
|
||||
0 && (
|
||||
<div>
|
||||
{intl.getMessage('upstream_ptr_faq_3', {
|
||||
value_1:
|
||||
dnsConfigState
|
||||
.default_local_ptr_upstreams[0],
|
||||
value_2:
|
||||
dnsConfigState
|
||||
.default_local_ptr_upstreams[1],
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
menuSize="large"
|
||||
spacing
|
||||
/>
|
||||
</>
|
||||
}
|
||||
placeholder={intl.getMessage('ip_addresses_placeholder')}
|
||||
size="medium"
|
||||
errorMessage={localPtrError()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={theme.form.input}>
|
||||
<Checkbox
|
||||
id="dns_use_private_ptr_resolvers"
|
||||
name="use_private_ptr_resolvers"
|
||||
checked={usePrivatePtrResolvers()}
|
||||
onChange={(e: Event) =>
|
||||
setUsePrivatePtrResolvers((e.target as HTMLInputElement).checked)
|
||||
}
|
||||
verticalAlign="start"
|
||||
>
|
||||
<div>
|
||||
<div class={theme.text.t2}>
|
||||
{intl.getMessage('upstream_private_ptr_resolvers_title')}
|
||||
</div>
|
||||
<div class={theme.text.t4}>
|
||||
{intl.getMessage('upstream_private_ptr_resolvers_desc')}
|
||||
</div>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<div class={theme.form.input}>
|
||||
<Checkbox
|
||||
id="dns_resolve_clients"
|
||||
name="resolve_clients"
|
||||
checked={resolveClients()}
|
||||
onChange={(e: Event) =>
|
||||
setResolveClients((e.target as HTMLInputElement).checked)
|
||||
}
|
||||
verticalAlign="start"
|
||||
>
|
||||
<div>
|
||||
<div class={theme.text.t2}>
|
||||
{intl.getMessage('upstream_enable_reverse_lookup_title')}
|
||||
</div>
|
||||
<div class={theme.text.t4}>
|
||||
{intl.getMessage('upstream_enable_reverse_lookup_desc')}
|
||||
</div>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<div class={theme.form.input}>
|
||||
<Input
|
||||
value={String(upstreamTimeout())}
|
||||
onChange={(e: Event) =>
|
||||
setUpstreamTimeout(Number((e.target as HTMLInputElement).value))
|
||||
}
|
||||
onBlur={validateUpstreamTimeout}
|
||||
type="number"
|
||||
id="upstream_timeout"
|
||||
label={
|
||||
<>
|
||||
{intl.getMessage('upstream_timeout')}
|
||||
<FaqTooltip
|
||||
text={intl.getMessage('upstream_timeout_faq')}
|
||||
menuSize="large"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
placeholder={intl.getMessage('upstream_timeout_placeholder')}
|
||||
errorMessage={upstreamTimeoutError()}
|
||||
min={UPSTREAM_TIMEOUT.MIN}
|
||||
max={UINT32_RANGE.MAX}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={theme.form.buttonGroup}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="small"
|
||||
id="dns_upstream_save"
|
||||
disabled={isSavingDisabled()}
|
||||
class={theme.form.button}
|
||||
>
|
||||
{intl.getMessage('apply')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="small"
|
||||
id="dns_upstream_test"
|
||||
onClick={handleUpstreamTest}
|
||||
disabled={isTestDisabled()}
|
||||
class={theme.form.button}
|
||||
>
|
||||
{intl.getMessage('test_upstreams')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { Form } from './Form';
|
||||
@@ -0,0 +1,66 @@
|
||||
import { untrack, type Accessor } from 'solid-js';
|
||||
import { dnsConfigState, setDnsConfig } from 'panel/stores/dnsConfig';
|
||||
import intl from 'panel/common/intl';
|
||||
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
|
||||
import { Textarea } from 'panel/common/controls/Textarea';
|
||||
import { validateUpstreams } from 'panel/helpers/validators';
|
||||
import { useField } from 'panel/hooks/useField';
|
||||
import { Examples } from './Examples';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
type Props = {
|
||||
open: Accessor<boolean>;
|
||||
onClose: () => void;
|
||||
processing: boolean;
|
||||
};
|
||||
|
||||
export const PrivateReverseServersDialog = (props: Props) => {
|
||||
const field = useField<string>(
|
||||
() => props.open(),
|
||||
() => dnsConfigState.local_ptr_upstreams,
|
||||
{ validate: (v) => (v ? validateUpstreams(v) || '' : '') },
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigDialog
|
||||
open={props.open()}
|
||||
title={intl.getMessage('dns_private_reverse_servers_title')}
|
||||
description={
|
||||
<>
|
||||
<p>{intl.getMessage('dns_private_reverse_servers_desc')}</p>
|
||||
{dnsConfigState.default_local_ptr_upstreams.length >= 2 && (
|
||||
<p>
|
||||
{intl.getMessage('dns_private_reverse_servers_desc_2', {
|
||||
value_1: dnsConfigState.default_local_ptr_upstreams[0],
|
||||
value_2: dnsConfigState.default_local_ptr_upstreams[1],
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onClose={props.onClose}
|
||||
onSubmit={() => {
|
||||
field.submitIfValid((v) => {
|
||||
setDnsConfig({ local_ptr_upstreams: v });
|
||||
untrack(() => props.onClose());
|
||||
});
|
||||
}}
|
||||
processing={props.processing}
|
||||
submitDisabled={!!field.error()}
|
||||
>
|
||||
<div class={theme.form.input}>
|
||||
<Textarea
|
||||
value={field.value()}
|
||||
onChange={(e: Event) => field.setValue((e.target as HTMLTextAreaElement).value)}
|
||||
onBlur={() => field.validate()}
|
||||
id="local_ptr_upstreams"
|
||||
label={intl.getMessage('dns_private_reverse_servers_label')}
|
||||
placeholder={intl.getMessage('dns_private_reverse_servers_placeholder')}
|
||||
errorMessage={field.error()}
|
||||
size="medium"
|
||||
/>
|
||||
</div>
|
||||
<Examples />
|
||||
</ConfigDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { untrack, type Accessor, Show } from 'solid-js';
|
||||
import { dnsConfigState, setDnsConfig } from 'panel/stores/dnsConfig';
|
||||
import intl from 'panel/common/intl';
|
||||
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
|
||||
import { Textarea } from 'panel/common/controls/Textarea';
|
||||
import { validateUpstreams } from 'panel/helpers/validators';
|
||||
import { useField } from 'panel/hooks/useField';
|
||||
import { Examples } from './Examples';
|
||||
import { ServerAddressesFileDialog } from './ServerAddressesFileDialog';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
type Props = {
|
||||
open: Accessor<boolean>;
|
||||
onClose: () => void;
|
||||
processing: boolean;
|
||||
};
|
||||
|
||||
export const ServerAddressesDialog = (props: Props) => {
|
||||
const hasFile = () => !!dnsConfigState.upstream_dns_file;
|
||||
|
||||
const field = useField<string>(
|
||||
() => props.open(),
|
||||
() => dnsConfigState.upstream_dns,
|
||||
{ validate: (v) => (v ? validateUpstreams(v) || '' : '') },
|
||||
);
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!hasFile()}
|
||||
fallback={<ServerAddressesFileDialog open={props.open} onClose={props.onClose} />}
|
||||
>
|
||||
<ConfigDialog
|
||||
open={props.open()}
|
||||
title={intl.getMessage('dns_server_addresses_title')}
|
||||
description={
|
||||
<>
|
||||
<p>{intl.getMessage('dns_server_addresses_desc')}</p>
|
||||
<p>
|
||||
{intl.getMessage('dns_server_addresses_desc_2', {
|
||||
a: (text: string) => (
|
||||
<a
|
||||
href="https://github.com/AdguardTeam/AdGuardHome/wiki/Configuration#upstreams"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class={theme.link.link}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
b: (text: string) => (
|
||||
<a
|
||||
href="https://link.adtidy.org/forward.html?action=dns_kb_providers&from=ui&app=home"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class={theme.link.link}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
onClose={props.onClose}
|
||||
onSubmit={() => {
|
||||
field.submitIfValid((v) => {
|
||||
setDnsConfig({ upstream_dns: v });
|
||||
untrack(() => props.onClose());
|
||||
});
|
||||
}}
|
||||
processing={props.processing}
|
||||
submitDisabled={!!field.error()}
|
||||
>
|
||||
<div class={theme.form.input}>
|
||||
<Textarea
|
||||
value={field.value()}
|
||||
onChange={(e: Event) =>
|
||||
field.setValue((e.target as HTMLTextAreaElement).value)
|
||||
}
|
||||
onBlur={() => field.validate()}
|
||||
id="upstream_dns"
|
||||
label={intl.getMessage('dns_server_addresses_label')}
|
||||
placeholder={intl.getMessage('dns_server_addresses_placeholder')}
|
||||
errorMessage={field.error()}
|
||||
size="medium"
|
||||
/>
|
||||
</div>
|
||||
<Examples />
|
||||
</ConfigDialog>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { type Accessor } from 'solid-js';
|
||||
import { dnsConfigState } from 'panel/stores/dnsConfig';
|
||||
import intl from 'panel/common/intl';
|
||||
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
type Props = {
|
||||
open: Accessor<boolean>;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const ServerAddressesFileDialog = (props: Props) => (
|
||||
<ConfigDialog
|
||||
open={props.open()}
|
||||
title={intl.getMessage('dns_server_addresses_title')}
|
||||
description={
|
||||
<p class={theme.common.breakWord}>
|
||||
{intl.getMessage('dns_server_addresses_configured_in_file', {
|
||||
path: dnsConfigState.upstream_dns_file,
|
||||
})}
|
||||
</p>
|
||||
}
|
||||
onClose={props.onClose}
|
||||
onSubmit={props.onClose}
|
||||
buttonText={intl.getMessage('close')}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,67 @@
|
||||
import { untrack, type Accessor } from 'solid-js';
|
||||
import { dnsConfigState, setDnsConfig } from 'panel/stores/dnsConfig';
|
||||
import intl from 'panel/common/intl';
|
||||
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
|
||||
import { Input } from 'panel/common/controls/Input';
|
||||
import { UPSTREAM_TIMEOUT } from 'panel/helpers/constants';
|
||||
import { validateBetween } from 'panel/helpers/validators';
|
||||
import { useField } from 'panel/hooks/useField';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
type Props = {
|
||||
open: Accessor<boolean>;
|
||||
onClose: () => void;
|
||||
processing: boolean;
|
||||
};
|
||||
|
||||
export const TimeoutDialog = (props: Props) => {
|
||||
const field = useField<number>(
|
||||
() => props.open(),
|
||||
() => dnsConfigState.upstream_timeout,
|
||||
{
|
||||
validate: (v) =>
|
||||
(Number.isNaN(v)
|
||||
? intl.getMessage('form_error_required')
|
||||
: validateBetween(v, UPSTREAM_TIMEOUT.MIN, UPSTREAM_TIMEOUT.MAX)) || '',
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigDialog
|
||||
open={props.open()}
|
||||
title={intl.getMessage('dns_upstream_timeout')}
|
||||
description={intl.getMessage('dns_upstream_timeout_desc')}
|
||||
onClose={props.onClose}
|
||||
onSubmit={() => {
|
||||
field.submitIfValid((v) => {
|
||||
setDnsConfig({ upstream_timeout: v });
|
||||
untrack(() => props.onClose());
|
||||
});
|
||||
}}
|
||||
processing={props.processing}
|
||||
submitDisabled={!!field.error()}
|
||||
>
|
||||
<div class={theme.form.input}>
|
||||
<Input
|
||||
type="number"
|
||||
value={field.value()}
|
||||
onChange={(e: Event) =>
|
||||
field.setValue(
|
||||
(e.target as HTMLInputElement).value === ''
|
||||
? NaN
|
||||
: Number((e.target as HTMLInputElement).value),
|
||||
)
|
||||
}
|
||||
onBlur={() => field.validate()}
|
||||
id="upstream_timeout"
|
||||
label={intl.getMessage('upstream_timeout')}
|
||||
placeholder={intl.getMessage('dns_upstream_timeout_placeholder')}
|
||||
min={UPSTREAM_TIMEOUT.MIN}
|
||||
max={UPSTREAM_TIMEOUT.MAX}
|
||||
errorMessage={field.error()}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
</ConfigDialog>
|
||||
);
|
||||
};
|
||||
-4
@@ -1,7 +1,3 @@
|
||||
.radioText {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.warning {
|
||||
margin-top: 8px;
|
||||
color: var(--default-attention-link);
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createSignal, createEffect, createMemo, type Accessor } from 'solid-js';
|
||||
import cn from 'clsx';
|
||||
import { dnsConfigState, setDnsConfig } from 'panel/stores/dnsConfig';
|
||||
import intl from 'panel/common/intl';
|
||||
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
|
||||
import { Radio } from 'panel/common/controls/Radio';
|
||||
import { getUpstreamModeOptions } from '../../helpers';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
import s from './UpstreamModeDialog.module.pcss';
|
||||
|
||||
type Props = {
|
||||
open: Accessor<boolean>;
|
||||
onClose: () => void;
|
||||
processing: boolean;
|
||||
};
|
||||
|
||||
export const UpstreamModeDialog = (props: Props) => {
|
||||
const [upstreamMode, setUpstreamMode] = createSignal(dnsConfigState.upstream_mode);
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open()) {
|
||||
setUpstreamMode(dnsConfigState.upstream_mode);
|
||||
}
|
||||
});
|
||||
|
||||
const upstreamModeOptions = createMemo(() => {
|
||||
return getUpstreamModeOptions().map((opt) => ({
|
||||
text: opt.text,
|
||||
value: opt.value,
|
||||
description: opt.warning ? (
|
||||
<>
|
||||
{opt.description}
|
||||
<div class={cn(theme.text.t3, s.warning)}>{opt.warning}</div>
|
||||
</>
|
||||
) : (
|
||||
opt.description
|
||||
),
|
||||
}));
|
||||
});
|
||||
|
||||
return (
|
||||
<ConfigDialog
|
||||
open={props.open()}
|
||||
title={intl.getMessage('dns_upstream_mode_title')}
|
||||
description={intl.getMessage('dns_upstream_mode_desc')}
|
||||
onClose={props.onClose}
|
||||
onSubmit={() => {
|
||||
setDnsConfig({ upstream_mode: upstreamMode() });
|
||||
props.onClose();
|
||||
}}
|
||||
processing={props.processing}
|
||||
>
|
||||
<Radio
|
||||
name="upstream_mode"
|
||||
options={upstreamModeOptions()}
|
||||
value={upstreamMode()}
|
||||
handleChange={(v: string) => setUpstreamMode(v)}
|
||||
inModal
|
||||
/>
|
||||
</ConfigDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import { DNS_REQUEST_OPTIONS, BLOCKING_MODES, EDNS_MODES } from 'panel/helpers/constants';
|
||||
import intl from 'panel/common/intl';
|
||||
|
||||
export const getUpstreamModeSummary = (mode: string): string => {
|
||||
switch (mode) {
|
||||
case DNS_REQUEST_OPTIONS.PARALLEL:
|
||||
return intl.getMessage('upstream_dns_parallel_requests');
|
||||
case DNS_REQUEST_OPTIONS.FASTEST_ADDR:
|
||||
return intl.getMessage('upstream_dns_fastest_addr');
|
||||
case DNS_REQUEST_OPTIONS.LOAD_BALANCING:
|
||||
default:
|
||||
return intl.getMessage('upstream_dns_load_balancing');
|
||||
}
|
||||
};
|
||||
|
||||
export const getUpstreamServersSummary = (servers: string, upstreamDnsFile?: string): string => {
|
||||
if (upstreamDnsFile) {
|
||||
return intl.getMessage('upstream_dns_configured_in_file', { path: upstreamDnsFile });
|
||||
}
|
||||
if (!servers) return '';
|
||||
const lines = servers.split('\n').filter(Boolean);
|
||||
return lines.join(', ');
|
||||
};
|
||||
|
||||
export const getRateLimitSummary = (ratelimit: number): string => {
|
||||
if (ratelimit === 0) return intl.getMessage('dns_rate_limit_no_limit');
|
||||
return intl.getMessage('dns_rate_limit_value', { value: ratelimit });
|
||||
};
|
||||
|
||||
export const getBlockingModeSummary = (mode: string): string => {
|
||||
switch (mode) {
|
||||
case BLOCKING_MODES.refused:
|
||||
return 'REFUSED';
|
||||
case BLOCKING_MODES.nxdomain:
|
||||
return 'NXDOMAIN';
|
||||
case BLOCKING_MODES.null_ip:
|
||||
return intl.getMessage('dns_blocking_mode_null_ip');
|
||||
case BLOCKING_MODES.custom_ip:
|
||||
return intl.getMessage('dns_blocking_mode_custom_ip');
|
||||
default:
|
||||
return intl.getMessage('dns_blocking_mode_default');
|
||||
}
|
||||
};
|
||||
|
||||
export const getCacheSizeSummary = (bytes?: number): string => {
|
||||
if (!bytes && bytes !== 0) return '';
|
||||
if (bytes < 1024) return `${bytes} bytes`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${Number((bytes / (1024 * 1024)).toFixed(1))} MB`;
|
||||
};
|
||||
|
||||
export const getTtlSummary = (seconds?: number): string => {
|
||||
if (seconds === undefined || seconds === null) return '';
|
||||
return intl.getMessage('dns_ttl_value', { value: seconds });
|
||||
};
|
||||
|
||||
export const getListSummary = (list: string): string => {
|
||||
if (!list || !list.trim()) return '';
|
||||
return list.split('\n').filter(Boolean).join(', ');
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns blocking mode options with i18n text.
|
||||
* Factory function — re-executes on each render for locale correctness.
|
||||
*/
|
||||
export const getBlockingModeOptions = () => {
|
||||
return [
|
||||
{
|
||||
text: intl.getMessage('dns_blocking_mode_default'),
|
||||
value: BLOCKING_MODES.default,
|
||||
description: intl.getMessage('dns_blocking_mode_default_desc'),
|
||||
},
|
||||
{
|
||||
text: intl.getMessage('dns_blocking_mode_refused'),
|
||||
value: BLOCKING_MODES.refused,
|
||||
description: intl.getMessage('dns_blocking_mode_refused_desc'),
|
||||
},
|
||||
{
|
||||
text: intl.getMessage('dns_blocking_mode_nxdomain'),
|
||||
value: BLOCKING_MODES.nxdomain,
|
||||
description: intl.getMessage('dns_blocking_mode_nxdomain_desc'),
|
||||
},
|
||||
{
|
||||
text: intl.getMessage('dns_blocking_mode_null_ip'),
|
||||
value: BLOCKING_MODES.null_ip,
|
||||
description: intl.getMessage('dns_blocking_mode_null_ip_desc'),
|
||||
},
|
||||
{
|
||||
text: intl.getMessage('dns_blocking_mode_custom_ip'),
|
||||
value: BLOCKING_MODES.custom_ip,
|
||||
description: intl.getMessage('dns_blocking_mode_custom_ip_desc'),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns EDNS mode options with i18n text.
|
||||
* Factory function — re-executes on each render for locale correctness.
|
||||
*/
|
||||
export const getEdnsOptions = () => {
|
||||
return [
|
||||
{
|
||||
text: intl.getMessage('dns_edns_option_default'),
|
||||
value: EDNS_MODES.default,
|
||||
},
|
||||
{
|
||||
text: intl.getMessage('dns_edns_option_custom'),
|
||||
value: EDNS_MODES.custom,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns upstream mode options with i18n text.
|
||||
* Factory function — re-executes on each call for locale correctness.
|
||||
* When used inside `createMemo`, tracks the locale signal and updates
|
||||
* option labels on language change.
|
||||
*/
|
||||
export const getUpstreamModeOptions = () => {
|
||||
return [
|
||||
{
|
||||
text: intl.getMessage('upstream_dns_load_balancing'),
|
||||
value: DNS_REQUEST_OPTIONS.LOAD_BALANCING,
|
||||
description: intl.getMessage('upstream_dns_load_balancing_desc'),
|
||||
},
|
||||
{
|
||||
text: intl.getMessage('upstream_dns_parallel_requests'),
|
||||
value: DNS_REQUEST_OPTIONS.PARALLEL,
|
||||
description: intl.getMessage('upstream_dns_parallel_requests_desc'),
|
||||
},
|
||||
{
|
||||
text: intl.getMessage('upstream_dns_fastest_addr'),
|
||||
value: DNS_REQUEST_OPTIONS.FASTEST_ADDR,
|
||||
description: intl.getMessage('upstream_dns_fastest_addr_desc'),
|
||||
warning: intl.getMessage('upstream_dns_fastest_addr_warning'),
|
||||
},
|
||||
];
|
||||
};
|
||||
+6
-5
@@ -50,12 +50,13 @@ type Props = {
|
||||
export const FiltersList = (props: Props) => {
|
||||
const { categories, filters } = filtersCatalog;
|
||||
|
||||
// eslint-disable-next-line solid/reactivity -- curried handler passed to JSX onChange (tracked)
|
||||
const handleToggle = (filterId: string) => (e: Event) => {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
untrack(() => props).onChange({
|
||||
...props.selectedIds,
|
||||
[filterId]: checked,
|
||||
untrack(() => {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
props.onChange({
|
||||
...props.selectedIds,
|
||||
[filterId]: checked,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -47,17 +47,17 @@ export const ListsTable = (props: Props) => {
|
||||
const sortedFilters = createMemo(() => {
|
||||
const items = [...(props.filters || [])];
|
||||
|
||||
// eslint-disable-next-line solid/reactivity -- sort callback inside createMemo; reads already tracked
|
||||
const direction = sortDirection();
|
||||
items.sort((a, b) => {
|
||||
const aName = (a.name || '').toLowerCase();
|
||||
const bName = (b.name || '').toLowerCase();
|
||||
|
||||
if (aName < bName) {
|
||||
return sortDirection() === 'asc' ? -1 : 1;
|
||||
return direction === 'asc' ? -1 : 1;
|
||||
}
|
||||
|
||||
if (aName > bName) {
|
||||
return sortDirection() === 'asc' ? 1 : -1;
|
||||
return direction === 'asc' ? 1 : -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
@@ -37,17 +37,17 @@ export const RewritesTable = (props: Props) => {
|
||||
const sortedList = createMemo(() => {
|
||||
const items = [...(props.list || [])];
|
||||
|
||||
// eslint-disable-next-line solid/reactivity -- sort callback inside createMemo; reads already tracked
|
||||
const direction = sortDirection();
|
||||
items.sort((a, b) => {
|
||||
const aDomain = a.domain.toLowerCase();
|
||||
const bDomain = b.domain.toLowerCase();
|
||||
|
||||
if (aDomain < bDomain) {
|
||||
return sortDirection() === 'asc' ? -1 : 1;
|
||||
return direction === 'asc' ? -1 : 1;
|
||||
}
|
||||
|
||||
if (aDomain > bDomain) {
|
||||
return sortDirection() === 'asc' ? 1 : -1;
|
||||
return direction === 'asc' ? 1 : -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createMemo, Show } from 'solid-js';
|
||||
import { createMemo, Show, untrack } from 'solid-js';
|
||||
|
||||
import intl from 'panel/common/intl';
|
||||
import { Loader } from 'panel/common/ui/Loader';
|
||||
@@ -40,12 +40,10 @@ type Props = {
|
||||
};
|
||||
|
||||
export const LogTable = (props: Props) => {
|
||||
/* eslint-disable solid/reactivity -- curried handler, called from JSX (tracked) */
|
||||
const handleSearchSelect = (value: string) => (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
props.onSearchSelect(value);
|
||||
untrack(() => props.onSearchSelect(value));
|
||||
};
|
||||
/* eslint-enable solid/reactivity */
|
||||
|
||||
const columns = createMemo<TableColumn<LogEntry>[]>(() => [
|
||||
{
|
||||
@@ -120,7 +118,7 @@ export const LogTable = (props: Props) => {
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
width: 40,
|
||||
width: 48,
|
||||
sortable: false,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -10,6 +10,7 @@ export const RoutePath = {
|
||||
Encryption: 'Encryption',
|
||||
Dhcp: 'Dhcp',
|
||||
Dns: 'Dns',
|
||||
DnsPrivateReverse: 'DnsPrivateReverse',
|
||||
SettingsPage: 'SettingsPage',
|
||||
Clients: 'Clients',
|
||||
DnsBlocklists: 'DnsBlocklists',
|
||||
@@ -41,6 +42,7 @@ export const Paths: Record<RoutePathKey, string> = {
|
||||
Encryption: pathBuilder('encryption'),
|
||||
Dhcp: pathBuilder('dhcp'),
|
||||
Dns: pathBuilder('dns'),
|
||||
DnsPrivateReverse: pathBuilder('dns/private-reverse'),
|
||||
SettingsPage: pathBuilder('settings'),
|
||||
Clients: pathBuilder('clients'),
|
||||
DnsBlocklists: pathBuilder('blocklists'),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createSignal, createEffect } from 'solid-js';
|
||||
import { createSignal, createEffect, createMemo } from 'solid-js';
|
||||
|
||||
import intl from 'panel/common/intl';
|
||||
import { QUERY_LOG_INTERVALS_DAYS, RETENTION_CUSTOM } from 'panel/helpers/constants';
|
||||
import { QUERY_LOG_INTERVALS_DAYS, RETENTION_CUSTOM, RETENTION_RANGE } from 'panel/helpers/constants';
|
||||
import { validateBetween } from 'panel/helpers/validators';
|
||||
|
||||
import { RadioGroup } from 'panel/common/ui/SettingsGroup';
|
||||
import { getIntervalTitle, getDefaultInterval } from '../helpers';
|
||||
@@ -16,6 +17,7 @@ type Props = {
|
||||
initialValues: Partial<FormValues>;
|
||||
processing: boolean;
|
||||
onValuesChange: (values: FormValues) => void;
|
||||
submitted?: boolean;
|
||||
};
|
||||
|
||||
export const Form = (props: Props) => {
|
||||
@@ -35,6 +37,18 @@ export const Form = (props: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Validate customInterval when Custom is selected
|
||||
const customIntervalError = createMemo(() => {
|
||||
const val = customInterval();
|
||||
if (intervalValue() !== RETENTION_CUSTOM) {
|
||||
return undefined;
|
||||
}
|
||||
if (val == null) {
|
||||
return props.submitted ? intl.getMessage('form_error_required') : undefined;
|
||||
}
|
||||
return validateBetween(val, RETENTION_RANGE.MIN, RETENTION_RANGE.MAX);
|
||||
});
|
||||
|
||||
// Notify parent of value changes for dirty tracking
|
||||
createEffect(() => {
|
||||
const values: FormValues = {
|
||||
@@ -68,6 +82,7 @@ export const Form = (props: Props) => {
|
||||
inputId="logs_config_custom_interval"
|
||||
inputLabel={intl.getMessage('settings_log_rotation_hours')}
|
||||
placeholder={intl.getMessage('settings_rotation_placeholder')}
|
||||
error={customIntervalError()}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { setLogsConfig, queryLogsState } from 'panel/stores/queryLogs';
|
||||
|
||||
import { Form, FormValues } from './Form';
|
||||
import { addSuccessToast } from 'panel/stores/toasts';
|
||||
import { RETENTION_CUSTOM, RETENTION_RANGE } from 'panel/helpers/constants';
|
||||
import { validateBetween } from 'panel/helpers/validators';
|
||||
|
||||
export type LogsConfigPayload = {
|
||||
interval: number;
|
||||
@@ -28,11 +30,14 @@ export const LogsConfig = (props: Props) => {
|
||||
});
|
||||
const [confirmConfig, setConfirmConfig] = createSignal<LogsConfigPayload | null>(null);
|
||||
|
||||
const [submitted, setSubmitted] = createSignal(false);
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.modalOpen,
|
||||
(open) => {
|
||||
if (!open) return;
|
||||
setSubmitted(false);
|
||||
setFormValues({
|
||||
interval: props.interval,
|
||||
customInterval: props.customInterval,
|
||||
@@ -42,11 +47,22 @@ export const LogsConfig = (props: Props) => {
|
||||
);
|
||||
|
||||
const handleFormChange = (values: FormValues) => {
|
||||
setSubmitted(false);
|
||||
setFormValues(values);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const values = formValues();
|
||||
|
||||
// Validate custom interval when Custom is selected
|
||||
if (values.interval === RETENTION_CUSTOM) {
|
||||
const val = values.customInterval;
|
||||
if (val == null || validateBetween(val, RETENTION_RANGE.MIN, RETENTION_RANGE.MAX)) {
|
||||
setSubmitted(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newInterval = resolveInterval(values.interval, values.customInterval);
|
||||
|
||||
// If decreasing retention, show confirmation
|
||||
@@ -100,6 +116,7 @@ export const LogsConfig = (props: Props) => {
|
||||
}}
|
||||
processing={props.processing}
|
||||
onValuesChange={handleFormChange}
|
||||
submitted={submitted()}
|
||||
/>
|
||||
</ConfigDialog>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createEffect, untrack } from 'solid-js';
|
||||
|
||||
import theme from 'panel/lib/theme';
|
||||
import { Input } from 'panel/common/controls/Input';
|
||||
import { RETENTION_CUSTOM, RETENTION_RANGE } from 'panel/helpers/constants';
|
||||
import { RETENTION_CUSTOM } from 'panel/helpers/constants';
|
||||
import { toNumber } from 'panel/helpers/form';
|
||||
|
||||
type Props = {
|
||||
@@ -48,8 +48,8 @@ export const RetentionCustomInput = (props: Props) => {
|
||||
}}
|
||||
disabled={props.processing || props.intervals.includes(props.intervalValue)}
|
||||
error={!!props.error}
|
||||
min={RETENTION_RANGE.MIN}
|
||||
max={RETENTION_RANGE.MAX}
|
||||
errorMessage={props.error}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createSignal, createEffect } from 'solid-js';
|
||||
import { createSignal, createEffect, createMemo } from 'solid-js';
|
||||
|
||||
import intl from 'panel/common/intl';
|
||||
import { RadioGroup } from 'panel/common/ui/SettingsGroup';
|
||||
|
||||
import { getIntervalTitle, getDefaultInterval } from '../helpers';
|
||||
import { STATS_INTERVALS_DAYS, RETENTION_CUSTOM } from 'panel/helpers/constants';
|
||||
import { STATS_INTERVALS_DAYS, RETENTION_CUSTOM, RETENTION_RANGE } from 'panel/helpers/constants';
|
||||
import { validateBetween } from 'panel/helpers/validators';
|
||||
import { RetentionCustomInput } from '../RetentionCustomInput';
|
||||
|
||||
export type FormValues = {
|
||||
@@ -16,6 +17,7 @@ type Props = {
|
||||
initialValues: Partial<FormValues>;
|
||||
processing: boolean;
|
||||
onValuesChange: (values: FormValues) => void;
|
||||
submitted?: boolean;
|
||||
};
|
||||
|
||||
export const Form = (props: Props) => {
|
||||
@@ -35,6 +37,18 @@ export const Form = (props: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Validate customInterval when Custom is selected
|
||||
const customIntervalError = createMemo(() => {
|
||||
const val = customInterval();
|
||||
if (intervalValue() !== RETENTION_CUSTOM) {
|
||||
return undefined;
|
||||
}
|
||||
if (val == null) {
|
||||
return props.submitted ? intl.getMessage('form_error_required') : undefined;
|
||||
}
|
||||
return validateBetween(val, RETENTION_RANGE.MIN, RETENTION_RANGE.MAX);
|
||||
});
|
||||
|
||||
// Notify parent of value changes for dirty tracking
|
||||
createEffect(() => {
|
||||
const values: FormValues = {
|
||||
@@ -68,6 +82,7 @@ export const Form = (props: Props) => {
|
||||
inputId="stats_config_custom_interval"
|
||||
inputLabel={intl.getMessage('settings_statistics_retention_hours')}
|
||||
placeholder={intl.getMessage('settings_rotation_placeholder')}
|
||||
error={customIntervalError()}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</>
|
||||
|
||||
@@ -9,6 +9,8 @@ import { setStatsConfig, statsState } from 'panel/stores/stats';
|
||||
|
||||
import { Form, FormValues } from './Form';
|
||||
import { addSuccessToast } from 'panel/stores/toasts';
|
||||
import { RETENTION_CUSTOM, RETENTION_RANGE } from 'panel/helpers/constants';
|
||||
import { validateBetween } from 'panel/helpers/validators';
|
||||
|
||||
export type StatsConfigPayload = {
|
||||
interval: number;
|
||||
@@ -29,11 +31,14 @@ export const StatsConfig = (props: Props) => {
|
||||
});
|
||||
const [confirmConfig, setConfirmConfig] = createSignal<StatsConfigPayload | null>(null);
|
||||
|
||||
const [submitted, setSubmitted] = createSignal(false);
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.modalOpen,
|
||||
(open) => {
|
||||
if (!open) return;
|
||||
setSubmitted(false);
|
||||
setFormValues({
|
||||
interval: props.interval,
|
||||
customInterval: props.customInterval,
|
||||
@@ -43,11 +48,22 @@ export const StatsConfig = (props: Props) => {
|
||||
);
|
||||
|
||||
const handleFormChange = (values: FormValues) => {
|
||||
setSubmitted(false);
|
||||
setFormValues(values);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const values = formValues();
|
||||
|
||||
// Validate custom interval when Custom is selected
|
||||
if (values.interval === RETENTION_CUSTOM) {
|
||||
const val = values.customInterval;
|
||||
if (val == null || validateBetween(val, RETENTION_RANGE.MIN, RETENTION_RANGE.MAX)) {
|
||||
setSubmitted(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newInterval = resolveInterval(values.interval, values.customInterval);
|
||||
|
||||
// If decreasing retention, show confirmation
|
||||
@@ -99,6 +115,7 @@ export const StatsConfig = (props: Props) => {
|
||||
}}
|
||||
processing={props.processing}
|
||||
onValuesChange={handleFormChange}
|
||||
submitted={submitted()}
|
||||
/>
|
||||
</ConfigDialog>
|
||||
|
||||
|
||||
@@ -220,6 +220,11 @@ export const BLOCKING_MODES = {
|
||||
custom_ip: 'custom_ip',
|
||||
};
|
||||
|
||||
export const EDNS_MODES = {
|
||||
default: 'default',
|
||||
custom: 'custom',
|
||||
};
|
||||
|
||||
// Note that translation strings contain these modes (theme_CONSTANT)
|
||||
// i.e. theme_auto, theme_light.
|
||||
export const THEMES = {
|
||||
@@ -506,13 +511,20 @@ export const RATE_LIMIT = {
|
||||
export const IPV4_SUBNET_PREFIX = {
|
||||
MIN: 0,
|
||||
MAX: 32,
|
||||
DEFAULT: 24,
|
||||
};
|
||||
|
||||
export const IPV6_SUBNET_PREFIX = {
|
||||
MIN: 0,
|
||||
MAX: 128,
|
||||
DEFAULT: 56,
|
||||
};
|
||||
|
||||
export const IP_VERSION = {
|
||||
V4: 'IPv4',
|
||||
V6: 'IPv6',
|
||||
} as const;
|
||||
|
||||
export const UPSTREAM_TIMEOUT = {
|
||||
MIN: 1,
|
||||
MAX: UINT32_RANGE.MAX,
|
||||
|
||||
@@ -260,24 +260,80 @@ export const validateIp = (value?: string): ValidationResult => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic per-line validator. Splits input by newlines, skips empty lines
|
||||
* (and optionally comment lines via `isContent`), then validates each
|
||||
* content line with `isValid`. Returns an appropriate i18n error message
|
||||
* based on how many content lines exist and how many are invalid.
|
||||
*
|
||||
* Message logic:
|
||||
* - 0 invalid → undefined (valid)
|
||||
* - 1 content line, 1 invalid → generic "Invalid format"
|
||||
* - Multiple content lines, 1 invalid → "Invalid format on line N"
|
||||
* - Multiple invalid → "Invalid format on lines N, M, ..."
|
||||
*
|
||||
* @param value - The textarea content
|
||||
* @param isValid - Returns true if the line is valid
|
||||
* @param isContent - Returns true if the line should be validated
|
||||
* (defaults to all non-empty lines)
|
||||
*/
|
||||
const validatePerLine = (
|
||||
value: string,
|
||||
isValid: (line: string) => boolean,
|
||||
isContent: (line: string) => boolean = () => true,
|
||||
): string | undefined => {
|
||||
if (!value) return undefined;
|
||||
const lines = value.split('\n');
|
||||
const invalidLines: number[] = [];
|
||||
let contentLineCount = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
if (!isContent(line)) continue;
|
||||
|
||||
contentLineCount += 1;
|
||||
if (!isValid(line)) {
|
||||
invalidLines.push(i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidLines.length === 0) return undefined;
|
||||
if (invalidLines.length === 1 && contentLineCount === 1) {
|
||||
return intl.getMessage('form_error_format');
|
||||
}
|
||||
if (invalidLines.length === 1) {
|
||||
return intl.getMessage('form_error_format_line', { line: invalidLines[0] });
|
||||
}
|
||||
return intl.getMessage('form_error_format_lines', {
|
||||
lines: invalidLines.join(', '),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that each non-empty line is a valid IP address.
|
||||
*
|
||||
* @example validateIpPerLine("192.168.1.1\n10.0.0.1") // undefined (valid)
|
||||
* @example validateIpPerLine("bad") // "Invalid IP address"
|
||||
*/
|
||||
export const validateIpPerLine = (value: string): string | undefined => {
|
||||
if (!value) return undefined;
|
||||
const lines = value.split('\n');
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i].trim();
|
||||
if (line) {
|
||||
const error = validateIp(line);
|
||||
if (error) return error;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
export const validateIpPerLine = (value: string): string | undefined =>
|
||||
validatePerLine(value, (line) => validateIp(line) === undefined);
|
||||
|
||||
/**
|
||||
* Validates that each non-empty line is a valid access client entry:
|
||||
* an IP address, CIDR range, or ClientID.
|
||||
*
|
||||
* @example validateClientsPerLine("192.168.1.1") // undefined (valid)
|
||||
* @example validateClientsPerLine("my-client-id") // undefined (valid)
|
||||
* @example validateClientsPerLine("bad entry!") // "Invalid format"
|
||||
*/
|
||||
export const validateClientsPerLine = (value: string): string | undefined =>
|
||||
validatePerLine(value, (line) =>
|
||||
R_IPV4.test(line) ||
|
||||
R_IPV6.test(line) ||
|
||||
R_CIDR.test(line) ||
|
||||
R_CIDR_IPV6.test(line) ||
|
||||
R_CLIENT_ID.test(line));
|
||||
|
||||
/**
|
||||
* Validates a MAC address format.
|
||||
@@ -561,6 +617,9 @@ export const validateHostname = (value?: string): ValidationResult => {
|
||||
const R_COMMENT = /^\s*#/;
|
||||
const R_HAS_ADDRESS = /[.:]/;
|
||||
|
||||
// A valid blocked_hosts entry contains at least one dot.
|
||||
const R_HAS_DOT = /[.]/;
|
||||
|
||||
/**
|
||||
* Validates upstream server lines. Each line must contain a dot or colon.
|
||||
*
|
||||
@@ -568,31 +627,29 @@ const R_HAS_ADDRESS = /[.:]/;
|
||||
* @example validateUpstreams("# comment\n1.1.1.1") // undefined (comments ok)
|
||||
* @example validateUpstreams("not-a-server") // "Invalid upstream"
|
||||
*/
|
||||
export const validateUpstreams = (value: string): string | undefined => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const lines = value.split('\n');
|
||||
const invalidLines: number[] = [];
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i].trim();
|
||||
if (line && !R_COMMENT.test(line) && !R_HAS_ADDRESS.test(line)) {
|
||||
invalidLines.push(i + 1);
|
||||
}
|
||||
}
|
||||
export const validateUpstreams = (value: string): string | undefined =>
|
||||
validatePerLine(
|
||||
value,
|
||||
(line) => R_HAS_ADDRESS.test(line),
|
||||
(line) => !R_COMMENT.test(line),
|
||||
);
|
||||
|
||||
if (invalidLines.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (invalidLines.length === 1) {
|
||||
return intl.getMessage('form_error_upstream_format', { line: invalidLines[0] });
|
||||
}
|
||||
|
||||
return intl.getMessage('form_error_upstream_format_multi', {
|
||||
lines: invalidLines.join(', '),
|
||||
});
|
||||
};
|
||||
/**
|
||||
* Validates that each non-empty, non-comment line in a blocked_hosts
|
||||
* entry contains a dot (domain, wildcard, or AdGuard URL filter rule).
|
||||
*
|
||||
* @example validateDomainsPerLine("example.org") // undefined (valid)
|
||||
* @example validateDomainsPerLine("*.example.org") // undefined (valid)
|
||||
* @example validateDomainsPerLine("||example.org^") // undefined (valid)
|
||||
* @example validateDomainsPerLine("# comment") // undefined (comments ok)
|
||||
* @example validateDomainsPerLine("notadomain") // "Invalid format"
|
||||
*/
|
||||
export const validateDomainsPerLine = (value: string): string | undefined =>
|
||||
validatePerLine(
|
||||
value,
|
||||
(line) => R_HAS_DOT.test(line),
|
||||
(line) => !R_COMMENT.test(line),
|
||||
);
|
||||
|
||||
interface LeaseEntry {
|
||||
ip: string;
|
||||
@@ -617,20 +674,11 @@ export const validateMacNotDuplicate =
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const validateMaxValue = (value: number, max: number): string | undefined => {
|
||||
if (value > max) {
|
||||
return intl.getMessage('form_value_length_common_error', {
|
||||
max_length: String(max),
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const validateBetween = (value: number, min: number, max: number): string | undefined => {
|
||||
if (value < min || value > max) {
|
||||
return intl.getMessage('form_value_value_between_error', {
|
||||
min_value: String(min),
|
||||
max_value: String(max),
|
||||
return intl.getMessage('form_value_value_from_error', {
|
||||
min_value: min.toLocaleString(),
|
||||
max_value: max.toLocaleString(),
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
@@ -639,7 +687,7 @@ export const validateBetween = (value: number, min: number, max: number): string
|
||||
export const validateMinValue = (value: number, min: number): string | undefined => {
|
||||
if (value < min) {
|
||||
return intl.getMessage('form_value_value_min_error', {
|
||||
min_value: String(min),
|
||||
min_value: min.toLocaleString(),
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
@@ -665,5 +713,5 @@ export const validateCacheSize = (size: number, enabled: boolean): ValidationRes
|
||||
if (size === 0) {
|
||||
return intl.getMessage('cache_config_size_validation');
|
||||
}
|
||||
return validateMaxValue(size, UINT32_RANGE.MAX);
|
||||
return validateBetween(size, 1, UINT32_RANGE.MAX);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { createSignal, createEffect, type Accessor, type Setter } from 'solid-js';
|
||||
|
||||
/**
|
||||
* Configuration for useField hook.
|
||||
*/
|
||||
type UseFieldConfig<T> = {
|
||||
/**
|
||||
* Optional validator function.
|
||||
* Should return an error message string on failure, or empty string on success.
|
||||
*/
|
||||
validate?: (value: T) => string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return type of the useField hook.
|
||||
*/
|
||||
type UseFieldResult<T> = {
|
||||
/** Current field value (reactive accessor). */
|
||||
value: Accessor<T>;
|
||||
/** Update the field value. */
|
||||
setValue: Setter<T>;
|
||||
/** Current validation error message (empty string when valid). */
|
||||
error: Accessor<string>;
|
||||
/** Manually override the error message. */
|
||||
setError: Setter<string>;
|
||||
/**
|
||||
* Run the validator, set the error signal, and return the error message.
|
||||
* Returns empty string if valid.
|
||||
*/
|
||||
validate: () => string;
|
||||
/**
|
||||
* Validate then submit. Runs the validator; if no error, calls onSubmit
|
||||
* with the current value. Returns true if submitted, false if validation failed.
|
||||
*/
|
||||
submitIfValid: (onSubmit: (value: T) => void) => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Composable hook that encapsulates the 4-signal + sync-effect pattern
|
||||
* used by DNS settings dialogs.
|
||||
*
|
||||
* When `isOpen` returns true, the field value resets from `getStoreValue()`
|
||||
* and the error is cleared — mirroring the original createEffect behavior.
|
||||
*
|
||||
* @param isOpen Getter that returns whether the dialog is open.
|
||||
* Called inside createEffect for proper reactivity tracking.
|
||||
* @param getStoreValue Lazily reads the store value on each effect re-run.
|
||||
* @param config Optional configuration (validator).
|
||||
*/
|
||||
export function useField<T>(
|
||||
isOpen: () => boolean,
|
||||
getStoreValue: () => T,
|
||||
config?: UseFieldConfig<T>,
|
||||
): UseFieldResult<T> {
|
||||
const [value, setValue] = createSignal<T>(getStoreValue());
|
||||
const [error, setError] = createSignal('');
|
||||
|
||||
// Sync from store when dialog opens — mirrors the original pattern:
|
||||
// createEffect(() => { if (open()) { setValue(storeValue); setError(''); } });
|
||||
createEffect(() => {
|
||||
if (isOpen()) {
|
||||
setValue(() => getStoreValue());
|
||||
setError('');
|
||||
}
|
||||
});
|
||||
|
||||
const validate = (): string => {
|
||||
const err = config?.validate ? config.validate(value()) : '';
|
||||
setError(err);
|
||||
return err;
|
||||
};
|
||||
|
||||
const submitIfValid = (onSubmit: (v: T) => void): boolean => {
|
||||
const err = validate();
|
||||
if (err) {
|
||||
return false;
|
||||
}
|
||||
onSubmit(value());
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
value,
|
||||
setValue,
|
||||
error,
|
||||
setError,
|
||||
validate,
|
||||
submitIfValid,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type of the useDialogOpen hook.
|
||||
*/
|
||||
type UseDialogOpenResult = {
|
||||
/** Reactive accessor for the dialog open state. */
|
||||
open: Accessor<boolean>;
|
||||
/** Opens the dialog. */
|
||||
openDialog: () => void;
|
||||
/** Closes the dialog. */
|
||||
closeDialog: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convenience hook for dialog open/close state.
|
||||
* Returns `open` signal, `openDialog`, and `closeDialog` helpers.
|
||||
*/
|
||||
export function useDialogOpen(): UseDialogOpenResult {
|
||||
const [open, setOpen] = createSignal(false);
|
||||
|
||||
return {
|
||||
open,
|
||||
openDialog: () => setOpen(true),
|
||||
closeDialog: () => setOpen(false),
|
||||
};
|
||||
}
|
||||
@@ -44,3 +44,12 @@
|
||||
font-size: 14px;
|
||||
color: var(--default-error-icon);
|
||||
}
|
||||
|
||||
.actionRow {
|
||||
padding: 8px 16px;
|
||||
|
||||
.actionButton {
|
||||
width: auto;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export const setAccessList = async (values: any) => {
|
||||
|
||||
await apiClient.setAccessList(config);
|
||||
setState({ ...values, processingSet: false });
|
||||
addSuccessToast(intl.getMessage('access_settings_saved_toast'));
|
||||
addSuccessToast(intl.getMessage('settings_notify_changes_saved'));
|
||||
} catch (error) {
|
||||
addErrorToast({ error });
|
||||
setState('processingSet', false);
|
||||
@@ -137,9 +137,9 @@ export const toggleClientBlock = async (
|
||||
processingSet: false,
|
||||
});
|
||||
addSuccessToast(
|
||||
intl.getMessage(disallowed ? 'client_unblocked' : 'client_blocked', {
|
||||
ip: disallowedRule || ip,
|
||||
}),
|
||||
disallowed
|
||||
? intl.getMessage('client_unblocked_flash')
|
||||
: intl.getMessage('client_blocked_flash'),
|
||||
);
|
||||
} catch (error) {
|
||||
addErrorToast({ error });
|
||||
|
||||
@@ -220,7 +220,9 @@ export const toggleDhcp = async (config?: any) => {
|
||||
const payload = { ...values, enabled };
|
||||
await apiClient.setDhcpConfig(payload);
|
||||
setState({ enabled, check: null, processingConfig: false });
|
||||
addSuccessToast(intl.getMessage(enabled ? 'enabled_dhcp' : 'disabled_dhcp'));
|
||||
addSuccessToast(
|
||||
enabled ? intl.getMessage('enabled_dhcp') : intl.getMessage('disabled_dhcp'),
|
||||
);
|
||||
} catch (error) {
|
||||
addErrorToast({ error });
|
||||
setState('processingConfig', false);
|
||||
|
||||
@@ -100,40 +100,76 @@ export const clearDnsCache = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
export const setDnsConfig = async (values: any, toastMessage?: string) => {
|
||||
/**
|
||||
* Toggles `use_private_ptr_resolvers`.
|
||||
* Called from the Private Reverse page's header switch.
|
||||
* NOT called from the main DNS settings page.
|
||||
*/
|
||||
export const togglePrivatePtrResolvers = () => {
|
||||
setDnsConfig({ use_private_ptr_resolvers: !state.use_private_ptr_resolvers }, { silent: true });
|
||||
};
|
||||
|
||||
/** Toggles `resolve_clients`. Used by PrivateReverse page switch. */
|
||||
export const toggleResolveClients = () => {
|
||||
setDnsConfig({ resolve_clients: !state.resolve_clients }, { silent: true });
|
||||
};
|
||||
|
||||
/** Toggles `dnssec_enabled`. Used by ServerConfig Section 2, Row 7. */
|
||||
export const toggleDnssecEnabled = () => {
|
||||
setDnsConfig({ dnssec_enabled: !state.dnssec_enabled }, { silent: true });
|
||||
};
|
||||
|
||||
/** Toggles `disable_ipv6` (inverted in UI). Used by ServerConfig Section 2, Row 8. */
|
||||
export const toggleDisableIPv6 = () => {
|
||||
setDnsConfig({ disable_ipv6: !state.disable_ipv6 }, { silent: true });
|
||||
};
|
||||
|
||||
/** Toggles `cache_enabled`. Used by Cache Section 3 header switch. */
|
||||
export const toggleCacheEnabled = () => {
|
||||
setDnsConfig({ cache_enabled: !state.cache_enabled }, { silent: true });
|
||||
};
|
||||
|
||||
/** Toggles `cache_optimistic`. Used by Cache Section 3, Row 4. */
|
||||
export const toggleOptimisticCaching = () => {
|
||||
setDnsConfig({ cache_optimistic: !state.cache_optimistic }, { silent: true });
|
||||
};
|
||||
|
||||
/** Toggles `edns_cs_enabled`. Used by ServerConfig Section 2, Row 6 switch-link. */
|
||||
export const toggleEdnsCsEnabled = () => {
|
||||
setDnsConfig({ edns_cs_enabled: !state.edns_cs_enabled }, { silent: true });
|
||||
};
|
||||
|
||||
export const setDnsConfig = async (
|
||||
values: any,
|
||||
opts?: { toastMessage?: string; silent?: boolean },
|
||||
) => {
|
||||
setState('processingSetConfig', true);
|
||||
try {
|
||||
const config = { ...values };
|
||||
let hasDnsSettings = false;
|
||||
|
||||
if (Object.hasOwn(config, 'bootstrap_dns')) {
|
||||
config.bootstrap_dns = splitByNewLine(config.bootstrap_dns);
|
||||
hasDnsSettings = true;
|
||||
}
|
||||
if (Object.hasOwn(config, 'fallback_dns')) {
|
||||
config.fallback_dns = splitByNewLine(config.fallback_dns);
|
||||
hasDnsSettings = true;
|
||||
}
|
||||
if (Object.hasOwn(config, 'local_ptr_upstreams')) {
|
||||
config.local_ptr_upstreams = splitByNewLine(config.local_ptr_upstreams);
|
||||
hasDnsSettings = true;
|
||||
}
|
||||
if (Object.hasOwn(config, 'upstream_dns')) {
|
||||
config.upstream_dns = splitByNewLine(config.upstream_dns);
|
||||
hasDnsSettings = true;
|
||||
}
|
||||
if (Object.hasOwn(config, 'ratelimit_whitelist')) {
|
||||
config.ratelimit_whitelist = splitByNewLine(config.ratelimit_whitelist);
|
||||
hasDnsSettings = true;
|
||||
}
|
||||
|
||||
await apiClient.setDnsConfig(config);
|
||||
setState(reconcile({ ...untrack(() => state), ...values, processingSetConfig: false }));
|
||||
|
||||
if (toastMessage) {
|
||||
addSuccessToast(toastMessage);
|
||||
} else if (hasDnsSettings) {
|
||||
addSuccessToast(intl.getMessage('updated_upstream_dns_toast'));
|
||||
if (opts?.silent) {
|
||||
// Toggle switches — no toast needed
|
||||
} else if (opts?.toastMessage) {
|
||||
addSuccessToast(opts.toastMessage);
|
||||
} else {
|
||||
addSuccessToast(intl.getMessage('settings_notify_changes_saved'));
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ export const testUpstreamWithFormValues = async (
|
||||
addErrorToast({
|
||||
error: intl.getMessage('dns_test_parsing_error_toast', {
|
||||
section: sectionKey,
|
||||
line,
|
||||
number: line,
|
||||
}),
|
||||
});
|
||||
} else if (message !== 'OK') {
|
||||
|
||||
Reference in New Issue
Block a user