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:
Ildar Kamalov
2026-07-09 13:57:00 +00:00
parent f49ee9fb79
commit b70aa19d5a
81 changed files with 3272 additions and 2006 deletions
+50 -47
View File
@@ -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
View File
@@ -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');
});
});
+16
View File
@@ -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':
+46 -42
View File
@@ -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}
+2 -3
View File
@@ -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'),
});
+2
View File
@@ -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>
);
};
@@ -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'),
},
];
};
@@ -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,
},
]);
+2
View File
@@ -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>
+12
View File
@@ -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,
+98 -50
View File
@@ -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);
};
+116
View File
@@ -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),
};
}
+9
View File
@@ -44,3 +44,12 @@
font-size: 14px;
color: var(--default-error-icon);
}
.actionRow {
padding: 8px 16px;
.actionButton {
width: auto;
min-width: 200px;
}
}
+4 -4
View File
@@ -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 });
+3 -1
View File
@@ -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);
+47 -11
View File
@@ -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'));
}
+1 -1
View File
@@ -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') {