AGDNS-4277

This commit is contained in:
Ildar Kamalov 2026-07-22 17:09:14 +03:00
parent 1aee4f3025
commit bac2eaae8b
74 changed files with 1350 additions and 1146 deletions

View file

@ -31,6 +31,7 @@ module.exports = {
'@typescript-eslint/no-unused-vars': [
'error',
{
varsIgnorePattern: '^_',
argsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},

View file

@ -19,7 +19,6 @@
"date-fns": "^4.1.0",
"ipaddr.js": "^2.2.0",
"js-yaml": "^4.1.0",
"lodash": "^4.17.19",
"nanoid": "^5.1.0",
"qs": "^6.14.0",
"solid-js": "^1.9.0",
@ -33,7 +32,6 @@
"@solidjs/testing-library": "^0.8.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/user-event": "^14.6.1",
"@types/lodash": "^4.17.4",
"@types/node": "^22.13.10",
"@types/qs": "^6.15.1",
"@typescript-eslint/eslint-plugin": "^8.60.1",
@ -5271,13 +5269,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@ -12439,6 +12430,7 @@
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.debounce": {

View file

@ -35,7 +35,6 @@
"date-fns": "^4.1.0",
"ipaddr.js": "^2.2.0",
"js-yaml": "^4.1.0",
"lodash": "^4.17.19",
"nanoid": "^5.1.0",
"qs": "^6.14.0",
"solid-js": "^1.9.0",
@ -49,7 +48,6 @@
"@solidjs/testing-library": "^0.8.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/user-event": "^14.6.1",
"@types/lodash": "^4.17.4",
"@types/node": "^22.13.10",
"@types/qs": "^6.15.1",
"@typescript-eslint/eslint-plugin": "^8.60.1",

View file

@ -8,8 +8,11 @@ import {
auditTranslations,
collectTranslationUsageFromFiles,
formatAuditReport,
formatPluralReport,
listSourceFiles,
loadLocaleMessages,
loadTwoskyLocales,
validatePlurals,
} from './translation-audit.js';
export const runTranslationAudit = async ({
@ -29,7 +32,13 @@ export const runTranslationAudit = async ({
const usage = await collectTranslationUsageFromFiles(filePaths);
const report = auditTranslations({ localeMessages, usage });
write(`${formatAuditReport(report, { rootDir })}\n`);
write(`${formatAuditReport(report, { rootDir })}\n\n`);
const repoRoot = path.resolve(rootDir, '..');
const localesDir = path.join(srcDir, '__locales');
const supportedLocales = await loadTwoskyLocales(repoRoot);
const pluralErrors = await validatePlurals(localesDir, supportedLocales);
write(`${formatPluralReport(pluralErrors)}\n`);
return 0;
} catch (error) {

View file

@ -1,11 +1,60 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { validator } from '@adguard/translate';
import ts from 'typescript';
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx']);
const TRANSLATION_METHODS = new Set(['getMessage', 'getPlural']);
/**
* Loads the locale-to-underscore map from .twosky.json in the repo root.
* Returns a Map where keys are hyphenated locale codes (e.g. "pt-br") and
* values are @adguard/translate locale codes (e.g. "pt_br"), or `null` for
* unsupported locales.
*
* @param {string} repoRoot - Path to the repository root (contains .twosky.json).
* @returns {Promise<Map<string, string|null>>}
*/
export const loadTwoskyLocales = async (repoRoot) => {
const twoskyPath = path.join(repoRoot, '.twosky.json');
const raw = await fs.readFile(twoskyPath, 'utf8');
const projects = JSON.parse(raw);
const homeV2 = projects.find((p) => p.project_id === 'home_v2');
if (!homeV2) {
throw new Error('home_v2 project not found in .twosky.json');
}
/**
* Converts a hyphenated twosky locale code to @adguard/translate format.
* Most become underscore (pt-br pt_br). Special cases for locales that
* the library handles under a parent code, or doesn't handle at all.
*/
const toTranslateLocale = (code) => {
// Sinhala not in @adguard/translate plural rules table
if (code === 'si-lk') {
return null;
}
// Hong Kong & Serbian Cyrillic → parent locale
if (code === 'zh-hk') {
return 'zh';
}
if (code === 'sr-cs') {
return 'sr';
}
return code.replace(/-/g, '_');
};
const map = new Map();
for (const code of Object.keys(homeV2.languages)) {
map.set(code, toTranslateLocale(code));
}
return map;
};
const isIntlMethod = (node) => {
if (!ts.isPropertyAccessExpression(node)) {
return false;
@ -216,3 +265,80 @@ export const collectTranslationUsageFromFiles = async (filePaths) => {
return usage;
};
/**
* Validates plural forms in all locale files against the @adguard/translate
* library's plural rules (CLDR-based). Returns an array of errors grouped by
* locale file and key.
*
* @param {string} localesDir - Path to __locales directory
* @param {{write?: (chunk: string) => void}} [options]
* @returns {Promise<Array<{file: string, key: string, value: string}>>}
*/
export const validatePlurals = async (localesDir, supportedLocales) => {
const errors = [];
const entries = await fs.readdir(localesDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.json')) {
continue;
}
const localeName = entry.name.replace(/\.json$/, '');
const localeCode = supportedLocales.get(localeName);
// `undefined` means not in twosky list; `null` means unsupported
if (localeCode === undefined || localeCode === null) {
continue;
}
const localePath = path.join(localesDir, entry.name);
const messages = await loadLocaleMessages(localePath);
for (const [key, value] of Object.entries(messages)) {
// Only validate strings that START with "|" (plural form separator).
// Strings containing "||" (like filter rule examples) are not plurals.
if (typeof value !== 'string' || !value.trimStart().startsWith('|')) {
continue;
}
if (!validator.isPluralFormValid(value, localeCode, key)) {
errors.push({ file: entry.name, key, value });
}
}
}
return errors;
};
/**
* Formats the plural validation report.
*
* @param {Array<{file: string, key: string, value: string}>} errors
* @returns {string}
*/
export const formatPluralReport = (errors) => {
if (errors.length === 0) {
return 'Plural form validation: all locales passed';
}
const byFile = new Map();
for (const err of errors) {
const list = byFile.get(err.file) || [];
list.push(err);
byFile.set(err.file, list);
}
const lines = [`Plural form errors: ${errors.length} keys`];
for (const [file, errs] of byFile) {
lines.push(`\n ${file}:`);
for (const { key, value } of errs) {
const pipeCount = (value.match(/\|/g) || []).length;
lines.push(` ${key} (${pipeCount} pipes)`);
lines.push(` "${value.slice(0, 80)}${value.length > 80 ? '…' : ''}"`);
}
}
return lines.join('\n');
};

View file

@ -208,7 +208,8 @@
"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": "Sets the DNS servers used to resolve the hostnames of DoH/DoT upstream resolvers",
"dns_bootstrap_dns_desc_2": "Enter one IP address per line. Comments must start on a new line with #",
"dns_bootstrap_dns_label": "Bootstrap DNS servers, one per line",
"dns_bootstrap_dns_placeholder": "IP addresses",
"dns_bootstrap_dns_title": "Bootstrap DNS servers",

View file

@ -16,7 +16,7 @@ vi.mock('panel/stores/toasts', () => ({
addErrorToast: mocks.addErrorToast,
}));
import { toggleClientBlock } from 'panel/stores/access';
import { toggleClientBlock, setAccessList } from 'panel/stores/access';
describe('toggleClientBlock', () => {
beforeEach(() => vi.clearAllMocks());
@ -77,3 +77,38 @@ describe('toggleClientBlock', () => {
});
});
});
describe('setAccessList', () => {
beforeEach(() => vi.clearAllMocks());
it('splits newline-delimited strings into arrays', async () => {
await setAccessList({
allowed_clients: '1.1.1.1\n2.2.2.2',
disallowed_clients: '3.3.3.3',
blocked_hosts: 'badhost.com\nevil.com',
});
expect(mocks.accessSet).toHaveBeenCalledWith({
allowed_clients: ['1.1.1.1', '2.2.2.2'],
disallowed_clients: ['3.3.3.3'],
blocked_hosts: ['badhost.com', 'evil.com'],
});
});
it('handles single values correctly', async () => {
await setAccessList({ allowed_clients: '1.1.1.1' });
expect(mocks.accessSet).toHaveBeenCalledWith({
allowed_clients: ['1.1.1.1'],
disallowed_clients: undefined,
blocked_hosts: undefined,
});
});
it('handles empty values', async () => {
await setAccessList({ allowed_clients: '' });
expect(mocks.accessSet).toHaveBeenCalledWith({
allowed_clients: [],
disallowed_clients: undefined,
blocked_hosts: undefined,
});
});
});

View file

@ -72,6 +72,7 @@ describe('clientForm store', () => {
duckduckgo: false,
yandex: false,
pixabay: false,
ecosia: false,
},
});
expect(clientFormState.safe_search.enabled).toBe(true);

View file

@ -44,9 +44,9 @@ describe('setTlsConfig', () => {
await setTlsConfig({
certificate_chain: '',
private_key: '',
port_https: '',
port_dns_over_tls: '',
port_dns_over_quic: '',
port_https: 0,
port_dns_over_tls: 0,
port_dns_over_quic: 0,
});
const sent = mocks.tlsConfigure.mock.calls[0][0];
expect(sent.port_https).toBe(0);
@ -89,7 +89,7 @@ describe('setTlsConfig', () => {
expect(encryptionState.warning_validation).toBe('');
expect(encryptionState.subject).toBe('');
expect(encryptionState.issuer).toBe('');
expect(encryptionState.key_type).toBe('');
expect(encryptionState.key_type).toBeUndefined();
expect(encryptionState.dns_names).toBeNull();
});

View file

@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest';
import {
msToSeconds,
msToMinutes,
msToHours,
secondsToMilliseconds,
splitByNewLine,
trimLinesAndRemoveEmpty,
normalizeRulesTextarea,
captitalizeWords,
getWebAddress,
getInterfaceIp,
isIpInCidr,
parseSubnetMask,
subnetMaskToBitMask,
} from '../../helpers/helpers';
describe('ms helpers', () => {
it('converts ms -> seconds', () => {
expect(msToSeconds(1500)).toBe(1);
});
it('converts ms -> minutes', () => {
expect(msToMinutes(120_000)).toBe(2);
});
it('converts ms -> hours', () => {
expect(msToHours(3_600_000)).toBe(1);
});
});
describe('secondsToMilliseconds', () => {
it('multiplies by 1000', () => {
expect(secondsToMilliseconds(3)).toBe(3000);
});
it('returns falsy input as-is', () => {
expect(secondsToMilliseconds(0)).toBe(0);
// The current implementation returns `seconds` unchanged if falsy
expect(secondsToMilliseconds(undefined as unknown as number)).toBe(undefined);
});
});
describe('splitByNewLine', () => {
it('splits and removes empty lines', () => {
expect(splitByNewLine('a\nb\n\nc')).toStrictEqual(['a', 'b', 'c']);
});
it('returns [] for falsy input', () => {
expect(splitByNewLine('')).toStrictEqual([]);
expect(splitByNewLine(undefined as unknown as string)).toStrictEqual([]);
});
});
describe('trimLinesAndRemoveEmpty', () => {
it('trims lines', () => {
expect(trimLinesAndRemoveEmpty(' a \n\n b ')).toBe('a\nb');
});
});
describe('normalizeRulesTextarea', () => {
it('strips leading newlines and collapses repeated blank lines', () => {
expect(normalizeRulesTextarea('\na\n\nb')).toBe('a\nb');
});
});
describe('captitalizeWords', () => {
it('capitalizes each word splitting by space, dash, or underscore', () => {
expect(captitalizeWords('safe_browsing mode-test')).toBe('Safe Browsing Mode Test');
});
});
describe('getWebAddress', () => {
it('builds http url omitting standard port 80', () => {
expect(getWebAddress('192.168.1.1', 80)).toBe('http://192.168.1.1');
});
it('appends non-standard port', () => {
expect(getWebAddress('192.168.1.1', 8080)).toBe('http://192.168.1.1:8080');
});
it('brackets IPv6 with zone encoding', () => {
expect(getWebAddress('fe80::1%eth0', 80)).toBe('http://[fe80::1%25eth0]');
});
});
describe('getInterfaceIp', () => {
it('prefers IPv4 over IPv6', () => {
expect(getInterfaceIp({ ip_addresses: ['10.0.0.1', 'fe80::1'] })).toBe('10.0.0.1');
});
it('skips IPv6 link-local when IPv4 present', () => {
expect(getInterfaceIp({ ip_addresses: ['192.168.1.1', 'fe80::1'] })).toBe('192.168.1.1');
});
it('falls back to IPv6 global without zone', () => {
expect(getInterfaceIp({ ip_addresses: ['2001:db8::1'] })).toBe('2001:db8::1');
});
it('returns undefined when no addresses', () => {
expect(getInterfaceIp({ ip_addresses: [] })).toBeUndefined();
});
});
describe('isIpInCidr', () => {
it('matches IP inside CIDR', () => {
expect(isIpInCidr('192.168.1.5', '192.168.1.0/24')).toBe(true);
});
it('rejects IP outside CIDR', () => {
expect(isIpInCidr('10.0.0.1', '192.168.1.0/24')).toBe(false);
});
});
describe('parseSubnetMask', () => {
it('returns prefix length for valid mask', () => {
expect(parseSubnetMask('255.255.255.0')).toBe(24);
});
it('returns null for invalid mask string', () => {
expect(parseSubnetMask('not-a-mask')).toBeNull();
});
});
describe('subnetMaskToBitMask', () => {
it('computes prefix length from dotted mask', () => {
expect(subnetMaskToBitMask('255.255.255.0')).toBe(24);
});
});

View file

@ -0,0 +1,199 @@
import { describe, expect, it } from 'vitest';
import {
normalizeTopStats,
addClientInfo,
normalizeTopClients,
normalizeFilters,
normalizeFilteringStatus,
getParamsForClientsSearch,
checkFiltered,
checkBlockedService,
getPathWithQueryString,
getSpecialFilterName,
getServiceName,
normalizeWhois,
normalizeLogs,
} from '../../helpers/helpers';
import type { ClientsFindEntry } from '../../api/model/clientsFindEntry';
import type { FilteringReason } from '../../api/model/filteringReason';
describe('normalizeTopStats', () => {
it('converts {name -> count} objects to {name, count} array', () => {
expect(normalizeTopStats([{ '192.168.1.1': 42 }, { 'example.com': 5 }])).toStrictEqual([
{ name: '192.168.1.1', count: 42 },
{ name: 'example.com', count: 5 },
]);
});
});
describe('addClientInfo', () => {
it('resolves client info by param key', () => {
const data = [{ name: '192.168.1.1', count: 1 }];
const clients: ClientsFindEntry[] = [{ '192.168.1.1': { name: 'MyPhone' } }];
expect(addClientInfo(data, clients, 'name')).toStrictEqual([
{ name: '192.168.1.1', count: 1, info: { name: 'MyPhone' } },
]);
});
});
describe('normalizeTopClients', () => {
it('splits into auto/configured by name and info', () => {
const r = normalizeTopClients([
{ name: '192.168.1.1', count: 7, info: { name: 'MyPhone' } },
]);
expect(r.auto).toStrictEqual({ '192.168.1.1': 7 });
expect(r.configured).toStrictEqual({ MyPhone: 7 });
});
});
describe('normalizeFilters', () => {
it('maps snake_case API fields to camelCase with defaults', () => {
expect(
normalizeFilters([
{
id: 1,
url: 'http://example.com/list.txt',
enabled: true,
last_updated: '2024-01-01',
name: 'My List',
rules_count: 100,
},
]),
).toStrictEqual([
{
id: 1,
url: 'http://example.com/list.txt',
enabled: true,
lastUpdated: '2024-01-01',
name: 'My List',
rulesCount: 100,
},
]);
});
it('returns [] for falsy input', () => {
expect(normalizeFilters(undefined)).toStrictEqual([]);
});
});
describe('normalizeFilteringStatus', () => {
it('normalizes full status with user_rules', () => {
const r = normalizeFilteringStatus({
enabled: true,
filters: [],
whitelist_filters: [],
user_rules: ['rule1', 'rule2'],
interval: 24,
});
expect(r.enabled).toBe(true);
expect(r.interval).toBe(24);
expect(r.userRules).toBe('rule1\nrule2');
});
});
describe('getParamsForClientsSearch', () => {
it('collects unique client ids from TopStat[]', () => {
expect(
getParamsForClientsSearch(
[
{ name: 'client-a', count: 1 },
{ name: 'client-b', count: 2 },
],
'name',
),
).toStrictEqual({ clients: [{ id: 'client-a' }, { id: 'client-b' }] });
});
it('includes additional param when provided', () => {
const r = getParamsForClientsSearch([{ name: 'a', count: 1 }], 'name', 'count');
expect(r.clients).toStrictEqual([{ id: 'a' }, { id: 1 }]);
});
});
describe('checkFiltered / checkBlockedService', () => {
it('checkFiltered returns true for Filtered* reasons', () => {
expect(checkFiltered('FilteredBlackList' as FilteringReason)).toBe(true);
});
it('checkFiltered returns false for NotFiltered* reasons', () => {
expect(checkFiltered('NotFilteredNotFound' as FilteringReason)).toBe(false);
});
it('checkBlockedService returns true for FilteredBlockedService', () => {
expect(checkBlockedService('FilteredBlockedService' as FilteringReason)).toBe(true);
});
});
describe('getPathWithQueryString', () => {
it('serializes params, skips empty/undefined values, repeats arrays', () => {
const r = getPathWithQueryString('/endpoint', {
a: '1',
b: '',
c: undefined,
d: ['x', 'y'],
});
expect(r).toBe('/endpoint?a=1&d=x&d=y');
});
it('handles null params gracefully', () => {
const r = getPathWithQueryString('/p', { a: '1', b: null });
expect(r).toBe('/p?a=1');
});
it('handles undefined params arg', () => {
expect(getPathWithQueryString('/p', undefined)).toBe('/p?');
});
});
describe('getSpecialFilterName', () => {
it('returns localized name for known special filter IDs', () => {
expect(typeof getSpecialFilterName(0)).toBe('string');
expect(typeof getSpecialFilterName(-1)).toBe('string');
expect(typeof getSpecialFilterName(-5)).toBe('string');
});
});
describe('getServiceName', () => {
it('returns name for matching service id', () => {
expect(getServiceName([{ id: 'svc1', name: 'My Service' }], 'svc1')).toBe('My Service');
});
it('returns undefined for unknown id', () => {
expect(getServiceName([{ id: 'svc1', name: 'My Service' }], 'svc-unknown')).toBeUndefined();
});
});
describe('normalizeWhois', () => {
it('derives location from city and country', () => {
expect(normalizeWhois({ city: 'NY', country: 'US', orgname: 'Example' })).toMatchObject({
location: 'US, NY',
orgname: 'Example',
});
});
it('uses only country when city absent', () => {
expect(normalizeWhois({ country: 'DE', orgname: 'Org' })).toMatchObject({
location: 'DE',
});
});
it('returns placeholder defaults for empty whois', () => {
expect(normalizeWhois({})).toMatchObject({
location: 'New York, US',
orgname: 'Example Organization',
});
});
});
describe('normalizeLogs', () => {
it('maps query log item to normalized shape', () => {
const [item] = normalizeLogs([
{
time: '2024-01-01T00:00:00Z',
question: {
name: 'example.com',
unicode_name: 'example.com',
type: 'A',
},
answer: [{ value: '1.2.3.4', type: 'A', ttl: 60 }],
status: 'processed',
},
]);
expect(item.domain).toBe('example.com');
expect(item.unicodeName).toBe('example.com');
expect(item.type).toBe('A');
expect(item.response).toStrictEqual([{ value: '1.2.3.4', type: 'A', ttl: 60 }]);
});
});

View file

@ -1,106 +0,0 @@
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('rejects !-prefixed filter rule that would otherwise pass dot check', () => {
expect(validateDomainsPerLine('! ||example.org^')).toBeTruthy();
});
it('rejects only ! lines as invalid', () => {
expect(validateDomainsPerLine('! first\n! second')).toBeTruthy();
});
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');
});
});

View file

@ -113,4 +113,34 @@ describe('queryLogs store', () => {
expect(queryLog).toHaveBeenCalledTimes(2);
expect(queryLogsState.processingGetLogs).toBe(false);
});
it('always sends limit=20 to prevent loading all records at once', async () => {
(queryLog as any).mockReset();
(queryLog as any)
.mockResolvedValueOnce({
data: Array.from({ length: 20 }, () => ({ reason: 'Rewrite', question: {} })),
oldest: 'cursor1',
})
.mockResolvedValueOnce({
data: [{ reason: 'Rewrite', question: {} }],
oldest: '',
});
await setFilteredLogs({ search: '', status: 'rewritten', reason: 'all' });
for (const call of (queryLog as any).mock.calls) {
expect(call[0]).toHaveProperty('limit', 20);
}
expect(queryLog).not.toHaveBeenCalledWith(expect.not.objectContaining({ limit: 20 }));
(queryLog as any).mockReset();
(queryLog as any).mockResolvedValueOnce({
data: [{ reason: 'Rewrite', question: {} }],
oldest: '',
});
await getAdditionalLogs();
expect(queryLog).toHaveBeenCalledWith(expect.objectContaining({ limit: 20 }));
});
});

View file

@ -19,13 +19,13 @@ export interface DNSConfig {
* @minimum 0
* @maximum 32
*/
ratelimit_subnet_subnet_len_ipv4?: number;
ratelimit_subnet_len_ipv4?: number;
/**
* Length of the subnet mask for IPv6 addresses.
* @minimum 0
* @maximum 128
*/
ratelimit_subnet_subnet_len_ipv6?: number;
ratelimit_subnet_len_ipv6?: number;
/** List of IP addresses excluded from rate limiting. */
ratelimit_whitelist?: string[];
blocking_mode?: DNSConfigBlockingMode;

View file

@ -1,4 +1,4 @@
export type ParentalStatus200 = {
enable?: boolean;
enabled?: boolean;
sensitivity?: number;
};

View file

@ -1,6 +1,6 @@
import { createSignal } from 'solid-js';
import { I18nInterface, translate } from '@adguard/translate';
import { I18nInterface, Locale, translate } from '@adguard/translate';
import { BASE_LOCALE } from 'panel/helpers/twosky';
import en from 'panel/__locales/en.json';
@ -38,6 +38,19 @@ const LOCALES = {
const messages: LocalesTypes = LOCALES;
/**
* Converts a hyphenated twosky locale code to the underscore format that
* {@link https://github.com/AdguardTeam/translate @adguard/translate}
* expects for plural-form lookups (e.g. pt-br pt_br).
*/
const toTranslateLocale = (code: string): Locale => {
// zh-hk / sr-cs → parent locale
if (code === 'zh-hk') return 'zh' as Locale;
if (code === 'sr-cs') return 'sr' as Locale;
return code.replace(/-/g, '_') as Locale;
};
const resolveLanguage = (lng: string): LocalesType => {
const l = lng.toLowerCase();
@ -74,7 +87,7 @@ export const i18n = (lang: LocalesType) => {
const resolved = resolveLanguage(lang);
return {
getMessage: (key: string) => messages[resolved]?.[key] || '',
getUILanguage: () => resolved,
getUILanguage: () => toTranslateLocale(resolved),
getBaseMessage: (key: string) => messages.en![key] || key,
getBaseUILanguage: () => BASE_LOCALE as LocalesType,
};
@ -82,7 +95,7 @@ export const i18n = (lang: LocalesType) => {
const detectedLanguage = ((typeof window !== 'undefined' &&
typeof localStorage !== 'undefined' &&
LocalStorageHelper.getItem(LOCAL_STORAGE_KEYS.LANGUAGE)) ||
LocalStorageHelper.getItem<string>(LOCAL_STORAGE_KEYS.LANGUAGE)) ||
(typeof navigator !== 'undefined' && (navigator.language as string)) ||
BASE_LOCALE) as LocalesType;

View file

@ -4,7 +4,7 @@ import cn from 'clsx';
import theme from 'panel/lib/theme';
import { Dropdown } from 'panel/common/ui/Dropdown';
import { Icon } from 'panel/common/ui/Icon';
import intl, { LocalesType } from 'panel/common/intl';
import intl from 'panel/common/intl';
import { LOCAL_STORAGE_KEYS, LocalStorageHelper } from 'panel/helpers/localStorageHelper';
import { LanguageDropdown } from '../LanguageDropdown/LanguageDropdown';
@ -19,6 +19,8 @@ import {
import { dashboardState } from 'panel/stores/dashboard';
import s from './styles.module.pcss';
import { Lang } from 'panel/api/model/lang';
import { ProfileInfoTheme } from 'panel/api/model/profileInfoTheme';
export const Footer = () => {
const currentTheme = () => dashboardState.theme || THEMES.auto;
@ -50,7 +52,7 @@ export const Footer = () => {
return 'theme_light';
};
const changeLanguage = async (newLang: LocalesType) => {
const changeLanguage = async (newLang: Lang) => {
setHtmlLangAttr(newLang);
try {
await changeLanguageAction(newLang);
@ -61,7 +63,7 @@ export const Footer = () => {
}
};
const onThemeChange = (value: string) => {
const onThemeChange = (value: ProfileInfoTheme) => {
if (isLoggedIn()) {
changeTheme(value);
} else {
@ -127,7 +129,7 @@ export const Footer = () => {
onOpenChange={setThemeDropdownOpen}
menu={
<div class={theme.dropdown.menu}>
<For each={Object.values(THEMES)}>
<For each={Object.values(THEMES) as ProfileInfoTheme[]}>
{(v) => (
<button
type="button"
@ -163,7 +165,7 @@ export const Footer = () => {
value={currentLanguage()}
languages={LANGUAGES}
languageNames={LANGUAGE_NAMES}
onChange={(lang: string) => changeLanguage(lang as LocalesType)}
onChange={(lang: Lang) => changeLanguage(lang)}
class={s.dropdown}
position="bottomRight"
/>

View file

@ -420,7 +420,7 @@ type Props = {
export const Guide = (props: Props) => {
const serverName = () => encryptionState.server_name;
const portHttps = () => encryptionState.port_https;
const portHttps = () => Number(encryptionState.port_https) || 0;
const [activeTabLabel, setActiveTabLabel] = createSignal('Router');

View file

@ -1,6 +1,6 @@
import { type JSX } from 'solid-js';
import { Logo } from 'panel/common/ui/Sidebar';
import intl, { type LocalesType } from 'panel/common/intl';
import intl from 'panel/common/intl';
import { LanguageDropdown } from 'panel/common/ui/LanguageDropdown/LanguageDropdown';
import { setHtmlLangAttr } from 'panel/helpers/helpers';
import { changeLanguage as changeLanguageAction, dashboardState } from 'panel/stores/dashboard';
@ -8,6 +8,7 @@ import { changeLanguage as changeLanguageAction, dashboardState } from 'panel/st
import { LOCAL_STORAGE_KEYS, LocalStorageHelper } from 'panel/helpers/localStorageHelper';
import { LANGUAGES, LANGUAGE_NAMES } from 'panel/helpers/twosky';
import styles from './PublicHeader.module.pcss';
import { Lang } from 'panel/api/model/lang';
type Props = {
dropdownClass?: string;
@ -17,7 +18,7 @@ type Props = {
};
export const PublicHeader = (props: Props) => {
const changeLanguage = async (newLang: LocalesType) => {
const changeLanguage = async (newLang: Lang) => {
setHtmlLangAttr(newLang);
if (props.useLocalLanguage) {
@ -49,7 +50,7 @@ export const PublicHeader = (props: Props) => {
value={currentLanguage()}
languages={LANGUAGES}
languageNames={LANGUAGE_NAMES}
onChange={changeLanguage}
onChange={(lang: Lang) => changeLanguage(lang)}
class={props.dropdownClass}
position={props.dropdownPosition ?? 'bottomRight'}
/>

View file

@ -7,6 +7,7 @@ import { ConfirmDialog } from 'panel/common/ui/ConfirmDialog';
import { PageLoader } from 'panel/common/ui/Loader';
import { Select } from 'panel/common/controls/Select';
import { updateClientFormField, clientFormState } from 'panel/stores/clientForm';
import type { ClientFormState } from 'panel/initialState';
import { getBlockedServices, updateBlockedServices, servicesState } from 'panel/stores/services';
import theme from 'panel/lib/theme';
@ -47,7 +48,7 @@ export const InactivitySchedule = (props: Props) => {
const schedule = createMemo<ScheduleData | undefined>(() => {
return props.clientScope
? (clientFormState.blocked_services_schedule as unknown as ScheduleData)
: servicesState.list?.schedule;
: (servicesState.list?.schedule as ScheduleData | undefined);
});
const currentTimezone = () => schedule()?.time_zone;
@ -108,7 +109,11 @@ export const InactivitySchedule = (props: Props) => {
}
});
if (props.clientScope) {
updateClientFormField('blocked_services_schedule', newSchedule, true);
updateClientFormField(
'blocked_services_schedule',
newSchedule as ClientFormState['blocked_services_schedule'],
true,
);
} else {
updateBlockedServices({ ids: servicesState.list?.ids || [], schedule: newSchedule });
}
@ -124,7 +129,11 @@ export const InactivitySchedule = (props: Props) => {
});
newSchedule[day] = { start, end };
if (props.clientScope) {
updateClientFormField('blocked_services_schedule', newSchedule, true);
updateClientFormField(
'blocked_services_schedule',
newSchedule as ClientFormState['blocked_services_schedule'],
true,
);
} else {
updateBlockedServices({ ids: servicesState.list?.ids || [], schedule: newSchedule });
}

View file

@ -54,7 +54,7 @@ export const Clients = () => {
initClientForm(buildFormPayload(client));
navigate(
linkPathBuilder(RoutePath.ClientsEdit, {
clientName: encodeURIComponent(client.name),
clientName: encodeURIComponent(client.name ?? ''),
}),
);
};
@ -79,7 +79,7 @@ export const Clients = () => {
const serviceMap = createMemo(() => {
const map = new Map<string, WebService>();
(servicesState.allServices || []).forEach((svc) => {
map.set(svc.id, svc);
map.set(svc.id, svc as WebService);
});
return map;
});

View file

@ -31,7 +31,7 @@ type Props = {
export const PersistentClientsTable = (props: Props) => {
const pageSize = createMemo(
() => LocalStorageHelper.getItem(LOCAL_STORAGE_KEYS.CLIENTS_PAGE_SIZE) || undefined,
() => LocalStorageHelper.getItem<number>(LOCAL_STORAGE_KEYS.CLIENTS_PAGE_SIZE) || undefined,
);
const handleCopy = (text: string) => {
@ -51,10 +51,11 @@ export const PersistentClientsTable = (props: Props) => {
text: intl.getMessage('client_identifier'),
className: s.headerCell,
},
accessor: (row: Client) => row.ids.filter((id) => id.trim() !== '').join(','),
accessor: (row: Client) =>
(row.ids ?? []).filter((id) => id.trim() !== '').join(','),
sortable: true,
render: (_value: string, row: Client) => {
const { ids } = row;
const ids = row.ids ?? [];
// Filter out empty strings — the backend may return trailing empty
// entries that would inflate hiddenCount and cause a spurious comma.
const nonEmpty = ids.filter((id) => id.trim() !== '');
@ -196,7 +197,7 @@ export const PersistentClientsTable = (props: Props) => {
text: intl.getMessage('upstreams'),
className: s.headerCell,
},
accessor: (row: Client) => row.upstreams.length > 0,
accessor: (row: Client) => (row.upstreams ?? []).length > 0,
sortable: true,
render: (_value: boolean, row: Client) => (
<div class={theme.table.cell}>
@ -204,7 +205,7 @@ export const PersistentClientsTable = (props: Props) => {
<div class={theme.table.cellValueText}>
<span class={theme.common.textOverflow}>
{row.upstreams.length > 0
{(row.upstreams ?? []).length > 0
? intl.getMessage('settings_custom')
: intl.getMessage('settings_global')}
</span>
@ -218,10 +219,10 @@ export const PersistentClientsTable = (props: Props) => {
text: intl.getMessage('tags_title'),
className: s.headerCell,
},
accessor: (row: Client) => row.tags.join(','),
accessor: (row: Client) => (row.tags ?? []).join(','),
sortable: true,
render: (_value: string, row: Client) => (
<TagCell tags={row.tags} onCopy={handleCopy} />
<TagCell tags={row.tags ?? []} onCopy={handleCopy} />
),
},
{
@ -230,7 +231,8 @@ export const PersistentClientsTable = (props: Props) => {
text: intl.getMessage('requests_table_header'),
className: s.headerCell,
},
accessor: (row: Client) => props.normalizedTopClients?.configured[row.name] || 0,
accessor: (row: Client) =>
props.normalizedTopClients?.configured[row.name ?? ''] || 0,
sortable: true,
render: (_value: unknown, row: Client) => (
<div class={theme.table.cell}>
@ -241,7 +243,7 @@ export const PersistentClientsTable = (props: Props) => {
<div class={theme.table.cellValueText}>
<span class={theme.common.textOverflow}>
{(
props.normalizedTopClients?.configured[row.name] || 0
props.normalizedTopClients?.configured[row.name ?? ''] || 0
).toLocaleString()}
</span>
</div>
@ -278,7 +280,7 @@ export const PersistentClientsTable = (props: Props) => {
<button
type="button"
onClick={() => props.onDelete(row.name)}
onClick={() => props.onDelete(row.name ?? '')}
disabled={props.deleteDisabled}
class={cn(theme.table.action, theme.table.action_danger)}
title={intl.getMessage('delete_table_action')}
@ -312,7 +314,7 @@ export const PersistentClientsTable = (props: Props) => {
loading={props.loading}
pageSize={pageSize()}
onPageSizeChange={handlePageSizeChange}
getRowId={(row) => row.name}
getRowId={(row) => row.name ?? ''}
/>
);
};

View file

@ -19,7 +19,9 @@ type Props = {
export const RuntimeClientsTable = (props: Props) => {
const pageSize = createMemo(
() => LocalStorageHelper.getItem(LOCAL_STORAGE_KEYS.AUTO_CLIENTS_PAGE_SIZE) || undefined,
() =>
LocalStorageHelper.getItem<number>(LOCAL_STORAGE_KEYS.AUTO_CLIENTS_PAGE_SIZE) ||
undefined,
);
const columns = createMemo<TableColumn<AutoClient>[]>(() => [

View file

@ -168,7 +168,7 @@ export const Dashboard = () => {
/>
<TopClients
topClients={statsState.topClients}
topClients={statsState.topClients as any[]}
numDnsQueries={statsState.numDnsQueries}
/>

View file

@ -7,6 +7,7 @@ import theme from 'panel/lib/theme';
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
import { Input } from 'panel/common/controls/Input';
import { dhcpState } from 'panel/stores/dhcp';
import { calculateDhcpPlaceholdersIpv4 } from 'panel/helpers/helpers';
import {
validateIpv4,
validateIpv4RangeEnd,
@ -60,6 +61,21 @@ export const DhcpV4Modal = (props: Props) => {
}
});
const v4Placeholders = createMemo(() => {
const iface = dhcpState.interfaces?.[props.selectedInterface()];
const firstIpv4 = iface?.ipv4_addresses?.[0];
if (firstIpv4) {
return calculateDhcpPlaceholdersIpv4(firstIpv4, iface.gateway_ip);
}
return {
gateway_ip: '192.168.1.1',
subnet_mask: '255.255.255.0',
range_start: '192.168.1.100',
range_end: '192.168.1.200',
lease_duration: '86400',
};
});
const hasIpv4 = createMemo(
() =>
!!(
@ -167,7 +183,7 @@ export const DhcpV4Modal = (props: Props) => {
onBlur={onGatewayBlur}
id="v4_gateway_ip"
label={intl.getMessage('dhcp_form_gateway_address')}
placeholder="192.168.1.1"
placeholder={v4Placeholders().gateway_ip}
disabled={!hasIpv4()}
errorMessage={gatewayIpError()}
size="large"
@ -186,7 +202,7 @@ export const DhcpV4Modal = (props: Props) => {
}
onBlur={onRangeStartBlur}
id="v4_range_start"
placeholder="192.168.1.2"
placeholder={v4Placeholders().range_start}
disabled={!hasIpv4()}
errorMessage={rangeStartError()}
size="large"
@ -200,7 +216,7 @@ export const DhcpV4Modal = (props: Props) => {
}
onBlur={onRangeEndBlur}
id="v4_range_end"
placeholder="192.168.1.254"
placeholder={v4Placeholders().range_end}
disabled={!hasIpv4()}
errorMessage={rangeEndError()}
size="large"
@ -215,7 +231,7 @@ export const DhcpV4Modal = (props: Props) => {
onBlur={onSubnetBlur}
id="v4_subnet_mask"
label={intl.getMessage('dhcp_form_subnet_input')}
placeholder="255.255.255.0"
placeholder={v4Placeholders().subnet_mask}
disabled={!hasIpv4()}
errorMessage={subnetMaskError()}
size="large"
@ -229,7 +245,7 @@ export const DhcpV4Modal = (props: Props) => {
id="v4_lease_duration"
inputMode="numeric"
label={intl.getMessage('dhcp_form_lease_title')}
placeholder="86400"
placeholder={v4Placeholders().lease_duration}
disabled={!hasIpv4()}
size="large"
inputError={leaseDurationError()}

View file

@ -6,6 +6,7 @@ import theme from 'panel/lib/theme';
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
import { Input } from 'panel/common/controls/Input';
import { dhcpState } from 'panel/stores/dhcp';
import { calculateDhcpPlaceholdersIpv6 } from 'panel/helpers/helpers';
import { validateIpv6, validateLeaseTime } from 'panel/helpers/validators';
export type V6Config = {
@ -36,6 +37,8 @@ export const DhcpV6Modal = (props: Props) => {
}
});
const v6Placeholders = createMemo(() => calculateDhcpPlaceholdersIpv6());
const hasIpv6 = createMemo(
() =>
!!(
@ -81,7 +84,9 @@ export const DhcpV6Modal = (props: Props) => {
<Input
id="v6_range_start"
label={intl.getMessage('dhcp_form_range_title')}
placeholder={intl.getMessage('dhcp_form_range_start')}
placeholder={
v6Placeholders().range_start || intl.getMessage('dhcp_form_range_start')
}
value={rangeStart()}
onChange={(e: Event) => setRangeStart((e.target as HTMLInputElement).value)}
onBlur={validateRangeStart}
@ -94,7 +99,7 @@ export const DhcpV6Modal = (props: Props) => {
id="v6_lease_duration"
type="number"
label={intl.getMessage('dhcp_form_lease_title')}
placeholder="86400"
placeholder={v6Placeholders().lease_duration || '86400'}
value={leaseDuration()}
onChange={(e: Event) => setLeaseDuration((e.target as HTMLInputElement).value)}
onBlur={() => validateLeaseDuration()}

View file

@ -6,6 +6,7 @@ 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 type { DNSConfigBlockingMode } from 'panel/api/model';
import { getBlockingModeOptions } from '../../helpers';
import {
validateRequiredValue,
@ -90,7 +91,7 @@ export const BlockingModeDialog = (props: Props) => {
name="blocking_mode"
options={blockingModeOptions}
value={blockingMode()}
handleChange={(v: string) => setBlockingMode(v)}
handleChange={(v: DNSConfigBlockingMode) => setBlockingMode(v)}
inModal
/>
<Show when={blockingMode() === BLOCKING_MODES.custom_ip}>

View file

@ -28,6 +28,7 @@ export const BootstrapDnsDialog = (props: Props) => {
description={
<>
<p>{intl.getMessage('dns_bootstrap_dns_desc')}</p>
<p>{intl.getMessage('dns_bootstrap_dns_desc_2')}</p>
</>
}
onClose={props.onClose}

View file

@ -7,6 +7,7 @@ import { validateUpstreams } from 'panel/helpers/validators';
import { useField } from 'panel/hooks/useField';
import { Examples } from './Examples';
import theme from 'panel/lib/theme';
import { UPSTREAM_CONFIGURATION_WIKI_LINK } from 'panel/helpers/constants';
type Props = {
open: Accessor<boolean>;
@ -32,7 +33,7 @@ export const FallbackDnsDialog = (props: Props) => {
{intl.getMessage('dns_fallback_dns_desc_2', {
a: (text: string) => (
<a
href="https://github.com/AdguardTeam/AdGuardHome/wiki/Configuration#upstreams"
href={UPSTREAM_CONFIGURATION_WIKI_LINK}
target="_blank"
rel="noopener noreferrer"
class={theme.link.link}

View file

@ -8,6 +8,7 @@ import { useField } from 'panel/hooks/useField';
import { Examples } from './Examples';
import { ServerAddressesFileDialog } from './ServerAddressesFileDialog';
import theme from 'panel/lib/theme';
import { UPSTREAM_CONFIGURATION_WIKI_LINK } from 'panel/helpers/constants';
type Props = {
open: Accessor<boolean>;
@ -39,7 +40,7 @@ export const ServerAddressesDialog = (props: Props) => {
{intl.getMessage('dns_server_addresses_desc_2', {
a: (text: string) => (
<a
href="https://github.com/AdguardTeam/AdGuardHome/wiki/Configuration#upstreams"
href={UPSTREAM_CONFIGURATION_WIKI_LINK}
target="_blank"
rel="noopener noreferrer"
class={theme.link.link}

View file

@ -5,6 +5,7 @@ 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 type { DNSConfigUpstreamMode } from 'panel/api/model';
import theme from 'panel/lib/theme';
import s from './UpstreamModeDialog.module.pcss';
@ -55,7 +56,7 @@ export const UpstreamModeDialog = (props: Props) => {
name="upstream_mode"
options={upstreamModeOptions()}
value={upstreamMode()}
handleChange={(v: string) => setUpstreamMode(v)}
handleChange={(v: DNSConfigUpstreamMode) => setUpstreamMode(v)}
inModal
/>
</ConfigDialog>

View file

@ -1,7 +1,8 @@
import { DNS_REQUEST_OPTIONS, BLOCKING_MODES, EDNS_MODES } from 'panel/helpers/constants';
import intl from 'panel/common/intl';
import type { DNSConfigBlockingMode, DNSConfigUpstreamMode } from 'panel/api/model';
export const getUpstreamModeSummary = (mode: string): string => {
export const getUpstreamModeSummary = (mode: DNSConfigUpstreamMode): string => {
switch (mode) {
case DNS_REQUEST_OPTIONS.PARALLEL:
return intl.getMessage('upstream_dns_parallel_requests');
@ -27,7 +28,7 @@ export const getRateLimitSummary = (ratelimit: number): string => {
return intl.getMessage('dns_rate_limit_value', { value: ratelimit });
};
export const getBlockingModeSummary = (mode: string): string => {
export const getBlockingModeSummary = (mode: DNSConfigBlockingMode): string => {
switch (mode) {
case BLOCKING_MODES.refused:
return 'REFUSED';

View file

@ -132,9 +132,9 @@ export const Encryption = () => {
serve_plain_dns: encryptionState.serve_plain_dns,
server_name: encryptionState.server_name,
force_https: encryptionState.force_https,
port_https: encryptionState.port_https,
port_dns_over_tls: encryptionState.port_dns_over_tls,
port_dns_over_quic: encryptionState.port_dns_over_quic,
port_https: Number(encryptionState.port_https) || 0,
port_dns_over_tls: Number(encryptionState.port_dns_over_tls) || 0,
port_dns_over_quic: Number(encryptionState.port_dns_over_quic) || 0,
certificate_chain: encryptionState.certificate_chain,
private_key: encryptionState.private_key,
certificate_path: encryptionState.certificate_path,

View file

@ -69,9 +69,9 @@ export const AddTlsCertModal = (props: Props) => {
enabled: encryptionState.enabled,
serve_plain_dns: encryptionState.serve_plain_dns,
server_name: encryptionState.server_name,
port_https: encryptionState.port_https || 0,
port_dns_over_tls: encryptionState.port_dns_over_tls || 0,
port_dns_over_quic: encryptionState.port_dns_over_quic || 0,
port_https: Number(encryptionState.port_https) || 0,
port_dns_over_tls: Number(encryptionState.port_dns_over_tls) || 0,
port_dns_over_quic: Number(encryptionState.port_dns_over_quic) || 0,
certificate_chain: certChain(),
private_key: privateKey(),
certificate_path: certPath(),

View file

@ -27,9 +27,9 @@ export const ServerSettingsModal = (props: Props) => {
(open) => {
if (open) {
setServerName(encryptionState.server_name || '');
setPortHttps(encryptionState.port_https || 0);
setPortDot(encryptionState.port_dns_over_tls || 0);
setPortDoq(encryptionState.port_dns_over_quic || 0);
setPortHttps(Number(encryptionState.port_https) || 0);
setPortDot(Number(encryptionState.port_dns_over_tls) || 0);
setPortDoq(Number(encryptionState.port_dns_over_quic) || 0);
setErrors({});
}
},

View file

@ -123,7 +123,7 @@ export const DNSRewrites = () => {
<Show when={rewritesState.list.length > 0}>
<div class={cn(s.group, s.tableGroup)}>
<RewritesTable
list={rewritesState.list}
list={rewritesState.list as Rewrite[]}
processing={rewritesState.processing}
processingAdd={rewritesState.processingAdd}
processingUpdate={rewritesState.processingUpdate}

View file

@ -9,6 +9,7 @@ import { closeModal } from 'panel/stores/modals';
import theme from 'panel/lib/theme';
import { Button } from 'panel/common/ui/Button';
import { addFilter, editFilter, filteringState } from 'panel/stores/filtering';
import type { FilterSetUrlData } from 'panel/api/model/filterSetUrlData';
import { Input } from 'panel/common/controls/Input';
import { validatePath, validateRequiredValue } from 'panel/helpers/validators';
@ -72,7 +73,7 @@ export const ConfigureAllowlistModal = (props: Props) => {
break;
}
case MODAL_TYPE.EDIT_ALLOWLIST: {
editFilter(props.filterToEdit!.url, values, true);
editFilter(props.filterToEdit!.url, values as FilterSetUrlData, true);
break;
}
default: {

View file

@ -16,6 +16,7 @@ import {
filteringState,
} from 'panel/stores/filtering';
import type { Filter } from 'panel/helpers/helpers';
import type { FilterSetUrlData } from 'panel/api/model/filterSetUrlData';
import { validatePath, validateRequiredValue } from 'panel/helpers/validators';
import { ManualFilterForm } from 'panel/components/FilterLists/blocks/ConfigureBlocklistModal/blocks/ManualFilterForm';
import { Tabs } from 'panel/common/ui/Tabs';
@ -170,7 +171,7 @@ export const ConfigureBlocklistModal = (props: Props) => {
break;
}
case MODAL_TYPE.EDIT_BLOCKLIST: {
editFilter(props.filterToEdit!.url, values, false);
editFilter(props.filterToEdit!.url, values as FilterSetUrlData, false);
break;
}
default: {

View file

@ -91,7 +91,11 @@ export const ConfigureRewritesModal = (props: Props) => {
validateRequiredValue(answer()) ||
validateAnswer(answer()) ||
validateRewriteNotSame(domain(), answer()) ||
validateRewriteNotExists(domain(), rewritesState.list, props.rewriteToEdit?.domain);
validateRewriteNotExists(
domain(),
rewritesState.list as { domain: string }[],
props.rewriteToEdit?.domain,
);
setAnswerError(err || undefined);
return !err;
};

View file

@ -41,7 +41,8 @@ export const ListsTable = (props: Props) => {
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
const pageSize = createMemo(
() => LocalStorageHelper.getItem(LOCAL_STORAGE_KEYS.BLOCKLIST_PAGE_SIZE) || undefined,
() =>
LocalStorageHelper.getItem<number>(LOCAL_STORAGE_KEYS.BLOCKLIST_PAGE_SIZE) || undefined,
);
const sortedFilters = createMemo(() => {
@ -93,9 +94,7 @@ export const ListsTable = (props: Props) => {
return (
<div class={theme.table.cell}>
<span class={theme.table.cellLabel}>
{intl.getMessage('name_label')}
</span>
<span class={theme.table.cellLabel}>{intl.getMessage('name_label')}</span>
<div class={cn(theme.table.cellValueText, s.domainCellValue)}>
<span class={theme.common.textOverflow}>{value}</span>

View file

@ -28,7 +28,8 @@ export const RewritesTable = (props: Props) => {
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
const pageSize = createMemo(
() => LocalStorageHelper.getItem(LOCAL_STORAGE_KEYS.BLOCKLIST_PAGE_SIZE) || undefined,
() =>
LocalStorageHelper.getItem<number>(LOCAL_STORAGE_KEYS.BLOCKLIST_PAGE_SIZE) || undefined,
);
const sortedList = createMemo(() => {

View file

@ -31,7 +31,7 @@ import { getLogsUrlParams } from 'panel/helpers/helpers';
import { RoutePath, linkPathBuilder } from 'panel/components/Routes/Paths';
import { filterLogsByStatus } from './helpers';
import { LogEntry } from './types';
import { LogEntry, ResponseEntry } from './types';
import { Header } from './blocks/Header';
import { EmptyState, type EmptyStateMode } from './blocks/EmptyState/EmptyState';
import { LogTable } from './blocks/LogTable';
@ -109,7 +109,11 @@ export const QueryLog = () => {
(dashboardState.clients || []).flatMap(
(persistentClient: any) => persistentClient.ids ?? [],
);
const visibleLogs = () => filterLogsByStatus(queryLogsState.logs || [], currentStatus());
const visibleLogs = () =>
filterLogsByStatus(
(queryLogsState.logs || []) as { reason: string; originalResponse?: ResponseEntry[] }[],
currentStatus(),
);
const emptyStateMode = () => getEmptyStateMode(queryLogsState.enabled, queryLogsState.interval);
const hasMore = () => !queryLogsState.isEntireLog;
const logs = () => queryLogsState.logs || [];
@ -232,7 +236,7 @@ export const QueryLog = () => {
<div class={s.desktopView}>
<LogTable
logs={visibleLogs()}
logs={visibleLogs() as LogEntry[]}
emptyStateMode={emptyStateMode()}
hasMore={hasMore()}
isLoadingMore={isLoadingMore()}
@ -271,7 +275,7 @@ export const QueryLog = () => {
<For each={visibleLogs()}>
{(entry) => (
<LogCard
entry={entry}
entry={entry as LogEntry}
onRowClick={handleRowClick}
onBlock={handleBlockDomain}
onUnblock={handleUnblockDomain}

View file

@ -1,3 +1,5 @@
import type { FilteringReason } from 'panel/api/model/filteringReason';
export type ResponseEntry = {
value: string;
type?: string;
@ -47,7 +49,7 @@ export type LogEntry = {
unicodeName?: string;
type: string;
response: ResponseEntry[];
reason: string;
reason: FilteringReason;
client: string;
client_info: ClientInfo | null;
tracker: TrackerInfo | null;

View file

@ -61,7 +61,7 @@ export const Settings = () => {
const ss = safesearch();
if (!ss) return '';
const selected = Object.keys(SAFE_SEARCH_PROVIDERS)
.filter((key) => ss[key])
.filter((key) => (ss as Record<string, boolean>)[key])
.map(getSafeSearchProviderTitle);
return selected.join(', ');
});
@ -214,7 +214,9 @@ export const Settings = () => {
<SafeSearchModal
open={safesearchProvidersOpen()}
onClose={() => setSafesearchProvidersOpen(false)}
providers={settingsState.settingsList.safesearch}
providers={
settingsState.settingsList.safesearch as Record<string, boolean>
}
enabled={safesearchEnabled()}
processing={safesearchProcessing()}
onSave={handleSafeSearchSave}

View file

@ -125,6 +125,7 @@ export const MobileConfigForm = (props: Props) => {
onChange={handleHostChange}
error={!!hostError()}
errorMessage={hostError()}
size="large"
/>
</div>
<Show when={protocol() === MOBILE_CONFIG_LINKS.DOH}>
@ -138,6 +139,7 @@ export const MobileConfigForm = (props: Props) => {
onChange={handlePortChange}
error={!!portError()}
errorMessage={portError()}
size="large"
/>
</div>
</Show>
@ -169,6 +171,7 @@ export const MobileConfigForm = (props: Props) => {
onChange={handleClientIdChange}
error={!!clientIdError()}
errorMessage={clientIdError()}
size="large"
/>
</div>

View file

@ -22,7 +22,7 @@ import { RulesEditor } from './blocks/RulesEditor';
import { DNS_RECORD_TYPE_OPTIONS } from './types';
import { useUserRulesActions } from './useUserRulesActions';
import type { CheckFormValues } from './types';
import type { CheckFormValues, CheckResultData } from './types';
import s from './UserRules.module.pcss';
@ -61,7 +61,7 @@ export const UserRules = () => {
openDeleteRewriteModal,
resetCurrentRewrite,
} = useUserRulesActions({
checkResult: () => filteringState.check,
checkResult: () => filteringState.check as CheckResultData,
filteringEnabled: () => filteringState.enabled,
settingsList: () => settingsState.settingsList,
persistentClients: () => dashboardState.clients || [],
@ -83,7 +83,7 @@ export const UserRules = () => {
});
createEffect(() => {
if (filteringState.check?.hostname) {
if ((filteringState.check as CheckResultData)?.hostname) {
setIsResultVisible(true);
}
});
@ -130,7 +130,10 @@ export const UserRules = () => {
const showResultLoader = createMemo(() => isResultVisible() && isResultRefreshing());
const showResultCard = createMemo(
() => isResultVisible() && !isResultRefreshing() && Boolean(filteringState.check?.hostname),
() =>
isResultVisible() &&
!isResultRefreshing() &&
Boolean((filteringState.check as CheckResultData)?.hostname),
);
return (
@ -182,7 +185,7 @@ export const UserRules = () => {
<Show when={showResultCard()}>
<CheckResult
checkResult={filteringState.check}
checkResult={filteringState.check as CheckResultData}
processingRules={isActionProcessing()}
onDismiss={() => setIsResultVisible(false)}
onAction={handleAction}
@ -198,13 +201,17 @@ export const UserRules = () => {
<ConfigureRewritesModal
modalId={MODAL_TYPE.EDIT_REWRITE}
rewriteToEdit={currentRewrite()}
rewriteToEdit={
currentRewrite() as { answer: string; domain: string; enabled: boolean }
}
onSubmit={handleRewriteUpdate}
onClose={resetCurrentRewrite}
/>
<DeleteRewriteModal
rewriteToDelete={currentRewrite()}
rewriteToDelete={
currentRewrite() as { answer: string; domain: string; enabled: boolean }
}
setRewriteToDelete={setCurrentRewrite}
onConfirm={handleRewriteDelete}
/>

View file

@ -1,6 +1,7 @@
import intl from 'panel/common/intl';
import { FILTERED_STATUS, SPECIAL_FILTER_ID } from 'panel/helpers/constants';
import { checkFiltered, getFilterName, type Filter } from 'panel/helpers/helpers';
import type { FilteringReason } from 'panel/api/model/filteringReason';
import { CheckResultData, ResultAction, ResultActionKind } from './types';
@ -244,7 +245,7 @@ export const getCheckResultMeta = ({
source: intl.getMessage('system_host_files'),
};
default: {
const isFilteredReason = reason ? checkFiltered(reason) : false;
const isFilteredReason = reason ? checkFiltered(reason as FilteringReason) : false;
return {
tone: isFilteredReason ? 'blocked' : 'processed',

View file

@ -26,11 +26,11 @@ export const findPersistentClient = (clients: Client[], identifier?: string) =>
const normalizedIdentifier = normalizeClientIdentifier(identifier);
const matches = clients.filter((client) => {
if (normalizeClientIdentifier(client.name) === normalizedIdentifier) {
if (normalizeClientIdentifier(client.name ?? '') === normalizedIdentifier) {
return true;
}
return client.ids.some(
return (client.ids ?? []).some(
(clientId) => normalizeClientIdentifier(clientId) === normalizedIdentifier,
);
});

View file

@ -43,9 +43,9 @@ export type ResultAction = {
};
export type RewriteEntry = {
domain: string;
answer: string;
enabled: boolean;
domain?: string;
answer?: string;
enabled?: boolean;
};
export type RewriteDialogState = {

View file

@ -1,3 +1,5 @@
import type { DNSConfigBlockingMode } from 'panel/api/model';
export const R_URL_REQUIRES_PROTOCOL = /^https?:\/\/[^/\s]+(\/.*)?$/;
// matches hostname or *.wildcard
@ -5,9 +7,6 @@ export const R_HOST = /^(\*\.)?[\w.-]+$/;
export const R_IPV4 = /^(?:(?:^|\.)(?:2(?:5[0-5]|[0-4]\d)|1?\d?\d)){4}$/;
export const R_IPV6 =
/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
export const R_CIDR =
/^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))$/;
@ -34,10 +33,6 @@ export const R_CLIENT_ID = /^[a-z0-9-]{1,63}$/;
export const R_HOSTNAME = /^[a-z0-9-]+$/;
export const R_IPV4_SUBNET = /^([0-9]|[1-2][0-9]|3[0-2])?$/;
export const R_IPV6_SUBNET = /^([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])?$/;
export const MIN_PASSWORD_LENGTH = 8;
export const MAX_PASSWORD_LENGTH = 72;
@ -57,13 +52,6 @@ export const STATS_NAMES = {
replaced_safesearch: 'enforced_save_search',
};
export const STATUS_COLORS = {
blue: '#467fcf',
red: '#cd201f',
green: '#5eba00',
yellow: '#f1c40f',
};
export const REPOSITORY = {
URL: 'https://github.com/AdguardTeam/AdGuardHome',
TRACKERS_DB:
@ -83,26 +71,16 @@ export const TERMS_LINK =
export const UPSTREAM_CONFIGURATION_WIKI_LINK =
'https://github.com/AdguardTeam/AdGuardHome/wiki/Configuration#upstreams';
export const FILTERS_RELATIVE_LINK = '#filters';
export const ADDRESS_IN_USE_TEXT = 'address already in use';
export const INSTALL_FIRST_STEP = 1;
export const INSTALL_TOTAL_STEPS = 6;
export const SETTINGS_NAMES = {
filtering: 'filtering',
safebrowsing: 'safebrowsing',
parental: 'parental',
safesearch: 'safesearch',
};
export const STANDARD_DNS_PORT = 53;
export const STANDARD_WEB_PORT = 80;
export const STANDARD_HTTPS_PORT = 443;
export const DNS_OVER_TLS_PORT = 853;
export const DNS_OVER_QUIC_PORT = 853;
export const MIN_PORT = 1;
export const MAX_PORT = 65535;
export const EMPTY_DATE = '0001-01-01T00:00:00Z';
@ -110,9 +88,6 @@ export const EMPTY_DATE = '0001-01-01T00:00:00Z';
export const DEBOUNCE_TIMEOUT = 300;
export const DEBOUNCE_FILTER_TIMEOUT = 500;
export const CHECK_TIMEOUT = 1000;
export const HIDE_TOOLTIP_DELAY = 300;
export const SHOW_TOOLTIP_DELAY = 200;
export const MODAL_OPEN_TIMEOUT = 150;
export const UNSAFE_PORTS = [
1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 77, 79, 87, 95, 101, 102, 103,
@ -157,11 +132,6 @@ export const TAB_TYPE = {
MANUAL: 'manual',
} as const;
export const CLIENT_ID = {
MAC: 'mac',
IP: 'ip',
};
export const ENCRYPTION_SOURCE = {
PATH: 'path',
CONTENT: 'content',
@ -169,9 +139,6 @@ export const ENCRYPTION_SOURCE = {
};
export const FILTERED = 'Filtered';
export const NOT_FILTERED = 'NotFiltered';
export const DISABLED_STATS_INTERVAL = 0;
export const HOUR = 60 * 60 * 1000;
@ -183,15 +150,7 @@ export const QUERY_LOG_INTERVALS_DAYS = [HOUR * 6, DAY, DAY * 7, DAY * 30, DAY *
export const RETENTION_CUSTOM = 1;
export const RETENTION_CUSTOM_INPUT = 'custom_retention_input';
export const CUSTOM_INTERVAL = 'customInterval';
export const FILTERS_INTERVALS_HOURS = [0, 1, 12, 24, 72, 168];
// Note that translation strings contain these modes (blocking_mode_CONSTANT)
// i.e. blocking_mode_default, blocking_mode_null_ip
export const BLOCKING_MODES = {
export const BLOCKING_MODES: { readonly [K in DNSConfigBlockingMode]: K } = {
default: 'default',
refused: 'refused',
nxdomain: 'nxdomain',
@ -204,8 +163,6 @@ export const EDNS_MODES = {
custom: 'custom',
};
// Note that translation strings contain these modes (theme_CONSTANT)
// i.e. theme_auto, theme_light.
export const THEMES = {
auto: 'auto',
dark: 'dark',
@ -251,12 +208,14 @@ export const DEFAULT_LOGS_FILTER = {
reason: 'all',
};
export const DEFAULT_LANGUAGE = 'en';
export type QueryLogFilter = {
search: string;
status: string;
reason: string;
};
export const QUERY_LOGS_PAGE_LIMIT = 20;
export const LEASES_TABLE_DEFAULT_PAGE_SIZE = 20;
export const FILTERED_STATUS = {
FILTERED_BLACK_LIST: 'FilteredBlackList',
NOT_FILTERED_WHITE_LIST: 'NotFilteredWhiteList',
@ -344,10 +303,6 @@ export const QUERY_LOG_REASON_FILTER_QUERIES = Object.values(QUERY_LOG_REASON_FI
{},
);
export const RESPONSE_FILTER = QUERY_LOG_REASON_FILTER;
export const RESPONSE_FILTER_QUERIES = QUERY_LOG_REASON_FILTER_QUERIES;
export const QUERY_STATUS_COLORS = {
BLUE: 'blue',
GREEN: 'green',
@ -431,38 +386,7 @@ export const DNS_REQUEST_OPTIONS = {
PARALLEL: 'parallel',
FASTEST_ADDR: 'fastest_addr',
LOAD_BALANCING: 'load_balance',
};
export const DHCP_FORM_NAMES = {
DHCPv4: 'dhcpv4',
DHCPv6: 'dhcpv6',
DHCP_INTERFACES: 'dhcpInterfaces',
};
export const FORM_NAME = {
UPSTREAM: 'upstream',
DOMAIN_CHECK: 'domainCheck',
FILTER: 'filter',
REWRITES: 'rewrites',
LOGS_FILTER: 'logsFilter',
CLIENT: 'client',
LEASE: 'lease',
ACCESS: 'access',
BLOCKING_MODE: 'blockingMode',
ENCRYPTION: 'encryption',
FILTER_CONFIG: 'filterConfig',
LOG_CONFIG: 'logConfig',
SERVICES: 'services',
STATS_CONFIG: 'statsConfig',
INSTALL: 'install',
LOGIN: 'login',
CACHE: 'cache',
MOBILE_CONFIG: 'mobileConfig',
...DHCP_FORM_NAMES,
};
export const SMALL_SCREEN_SIZE = 767;
export const MEDIUM_SCREEN_SIZE = 1024;
} as const;
export const SECONDS_IN_DAY = 60 * 60 * 24;
@ -521,8 +445,6 @@ export const DHCP_VALUES_PLACEHOLDERS = {
},
};
export const TOAST_TRANSITION_TIMEOUT = 500;
export const TOAST_TYPES = {
SUCCESS: 'success',
ERROR: 'error',
@ -549,12 +471,6 @@ export const ADDRESS_TYPES = {
UNKNOWN: 'UNKNOWN',
};
export const CACHE_CONFIG_FIELDS = {
cache_size: 'cache_size',
cache_ttl_min: 'cache_ttl_min',
cache_ttl_max: 'cache_ttl_max',
};
export const COMMENT_LINE_DEFAULT_TOKEN = '#';
export const COMMENT_LINE_TOKENS = ['#', '!'] as const;
export type CommentLineToken = (typeof COMMENT_LINE_TOKENS)[number];
@ -573,14 +489,8 @@ export const DISABLE_PROTECTION_TIMINGS = {
TOMORROW: 24 * 60 * 60 * 1000,
};
export const LOCAL_TIMEZONE_VALUE = 'Local';
export const TABLES_MIN_ROWS = 5;
export const MOBILE_TABLE_MAX_ROWS = 5;
export const DASHBOARD_TABLES_DEFAULT_PAGE_SIZE = 100;
export const TIME_UNITS = {
HOURS: 'hours',
DAYS: 'days',

View file

@ -5,8 +5,10 @@ import { R_MAC_WITHOUT_COLON, R_UNIX_ABSOLUTE_PATH, R_WIN_ABSOLUTE_PATH } from '
* @param {string} ip
* @returns {*}
*/
export const ip4ToInt = (ip: any) => {
const intIp = ip.split('.').reduce((int: any, oct: any) => int * 256 + parseInt(oct, 10), 0);
export const ip4ToInt = (ip: string): number => {
const intIp = ip
.split('.')
.reduce((int: number, oct: string) => int * 256 + parseInt(oct, 10), 0);
return Number.isNaN(intIp) ? 0 : intIp;
};
@ -14,20 +16,20 @@ export const ip4ToInt = (ip: any) => {
* @param value {string}
* @returns {*|number}
*/
export const toNumber = (value: any) => value && parseInt(value, 10);
export const toNumber = (value: string): number | undefined => value && parseInt(value, 10);
/**
* @param value {string}
* @returns {*|number}
*/
export const toFloatNumber = (value: any) => value && parseFloat(value);
export const toFloatNumber = (value: string): number | undefined => value && parseFloat(value);
/**
* @param value {string}
* @returns {boolean}
*/
export const isValidAbsolutePath = (value: any) =>
export const isValidAbsolutePath = (value: string): boolean =>
R_WIN_ABSOLUTE_PATH.test(value) || R_UNIX_ABSOLUTE_PATH.test(value);
/**
@ -38,7 +40,7 @@ export const isValidAbsolutePath = (value: any) =>
* @example normalizeMac("AA-BB-CC-DD-EE-FF") // "AA:BB:CC:DD:EE:FF"
* @example normalizeMac("aa:bb:cc:dd:ee:ff") // "AA:BB:CC:DD:EE:FF"
*/
export const normalizeMac = (value: any) => {
export const normalizeMac = (value: string): string => {
if (!value || typeof value !== 'string') return value;
// Handle separator-less bare hex (12 or 16 chars)

View file

@ -1,5 +1,3 @@
import { parseISO, format as dateFormat } from 'date-fns';
import round from 'lodash/round';
import ipaddr, { IPv4, IPv6 } from 'ipaddr.js';
import queryString from 'qs';
import intl from 'panel/common/intl';
@ -10,13 +8,11 @@ import {
CHECK_TIMEOUT,
COMMENT_LINE_DEFAULT_TOKEN,
DEFAULT_DATE_FORMAT_OPTIONS,
DEFAULT_TIME_FORMAT,
DETAILED_DATE_FORMAT_OPTIONS,
DHCP_VALUES_PLACEHOLDERS,
FILTERED,
FILTERED_STATUS,
R_CLIENT_ID,
STANDARD_DNS_PORT,
STANDARD_HTTPS_PORT,
STANDARD_WEB_PORT,
SPECIAL_FILTER_ID,
@ -24,17 +20,51 @@ import {
SHORT_DATE_FORMAT_OPTIONS,
} from './constants';
import { LOCAL_STORAGE_KEYS, LocalStorageHelper } from './localStorageHelper';
import { DhcpInterfaces, InstallInterface } from '../initialState';
import type { NetInterfaces } from '../api/model/netInterfaces';
import { DhcpInterfaces } from '../initialState';
import type { NetInterfaces } from 'panel/api/model/netInterfaces';
import type { DnsAnswer } from 'panel/api/model/dnsAnswer';
import type { ResultRule } from 'panel/api/model/resultRule';
import type { FilteringReason } from 'panel/api/model/filteringReason';
import type { FilterStatus } from 'panel/api/model/filterStatus';
import type { TopArrayEntry } from 'panel/api/model/topArrayEntry';
import type { ClientsFindEntry } from 'panel/api/model/clientsFindEntry';
import type { ClientFindSubEntry } from 'panel/api/model/clientFindSubEntry';
import type { QueryLogItemClient } from 'panel/api/model/queryLogItemClient';
import type { QueryLogItemClientWhois } from 'panel/api/model/queryLogItemClientWhois';
import type { QueryLogItemClientProto } from 'panel/api/model/queryLogItemClientProto';
import type { QueryLogItem } from 'panel/api/model/queryLogItem';
/**
* @param time {string} The time to format
* @param options {string}
* @returns {string} Returns the time in the format HH:mm:ss
*/
export const formatTime = (time: any, options = DEFAULT_TIME_FORMAT) => {
const parsedTime = parseISO(time);
return dateFormat(parsedTime, options);
export type NormalizedDnsResponse = {
value?: string;
type?: string;
ttl?: number;
};
export type NormalizedQueryLogItem = {
time: string;
domain: string;
unicodeName: string;
type: string;
response: NormalizedDnsResponse[];
reason?: FilteringReason;
client: string;
client_proto?: QueryLogItemClientProto;
client_id?: string;
client_info: QueryLogItemClient | null;
filterId?: number; // @deprecated
rule?: string; // @deprecated
rules: ResultRule[];
status?: string;
service_name?: string;
serviceName?: string;
originalAnswer?: DnsAnswer[];
originalResponse: NormalizedDnsResponse[];
tracker: Record<string, unknown> | null;
answer_dnssec?: boolean;
elapsedMs?: string;
upstream?: string;
cached?: boolean;
ecs?: string;
};
/**
@ -69,8 +99,8 @@ export const formatDetailedDateTime = (dateTime: string) =>
export const formatShortDateTime = (dateTime: string) =>
formatDateTime(dateTime, SHORT_DATE_FORMAT_OPTIONS);
export const normalizeLogs = (logs: any) =>
logs.map((log: any) => {
export const normalizeLogs = (logs: QueryLogItem[]): NormalizedQueryLogItem[] =>
logs.map((log) => {
const {
answer,
answer_dnssec,
@ -95,9 +125,9 @@ export const normalizeLogs = (logs: any) =>
const { name: domain, unicode_name: unicodeName, type } = question || {};
const processResponse = (data: any) =>
const processResponse = (data: DnsAnswer[] | undefined): NormalizedDnsResponse[] =>
Array.isArray(data)
? data.map((response: any) => {
? data.map((response: DnsAnswer) => {
const { value, type, ttl } = response;
return {
@ -153,28 +183,25 @@ export const normalizeLogs = (logs: any) =>
};
});
// TODO (ik) type will fixed in query log task
export const normalizeHistory = (history: any) =>
history.map((item: any, idx: number) => ({
x: idx,
y: item,
}));
export const normalizeTopStats = (stats: any) =>
stats.map((item: any) => ({
export const normalizeTopStats = (stats: TopArrayEntry[]): TopStat[] =>
stats.map((item: TopArrayEntry) => ({
name: Object.keys(item)[0],
count: Object.values(item)[0],
count: Object.values(item)[0] as number,
}));
export const addClientInfo = (data: any, clients: any, ...params: any[]) =>
data.map((row: any) => {
let info = '';
export const addClientInfo = (
data: TopStat[],
clients: ClientsFindEntry[],
...params: string[]
): (TopStat & { info: ClientFindSubEntry })[] =>
data.map((row: TopStat) => {
let info: ClientFindSubEntry | null = null;
params.find((param) => {
const id = row[param];
const id = row[param as keyof TopStat];
if (id) {
const client = clients.find((item: any) => item[id]) || '';
info = client?.[id] ?? '';
const clientData = clients.find((item: ClientsFindEntry) => item[String(id)]);
info = clientData?.[String(id)] ?? null;
}
return info;
@ -182,13 +209,13 @@ export const addClientInfo = (data: any, clients: any, ...params: any[]) =>
return {
...row,
info,
info: info ?? {},
};
});
export const normalizeFilters = (filters: any) =>
export const normalizeFilters = (filters: FilterStatus['filters']) =>
filters
? filters.map((filter: any) => {
? filters.map((filter) => {
const {
id,
url,
@ -209,7 +236,15 @@ export const normalizeFilters = (filters: any) =>
})
: [];
export const normalizeFilteringStatus = (filteringStatus: any) => {
export const normalizeFilteringStatus = (
filteringStatus: FilterStatus,
): {
enabled: boolean | undefined;
userRules: string;
filters: Filter[];
whitelistFilters: Filter[];
interval: number | undefined;
} => {
const {
enabled,
filters,
@ -228,21 +263,20 @@ export const normalizeFilteringStatus = (filteringStatus: any) => {
};
};
export const getPercent = (amount: any, number: any) => {
if (amount > 0 && number > 0) {
return round(100 / (amount / number), 2);
}
return 0;
};
export const captitalizeWords = (text: any) =>
export const captitalizeWords = (text: string): string =>
text
.split(/[ -_]/g)
.map((str: any) => str.charAt(0).toUpperCase() + str.substr(1))
.map((str: string) => str.charAt(0).toUpperCase() + str.substr(1))
.join(' ');
export const getInterfaceIp = (option: any) => {
const addresses = (option?.ip_addresses ?? []).filter((ip: any) => typeof ip === 'string');
type InterfaceWithIpAddresses = { ip_addresses?: string[] };
type TopStat = { name: string; count: number };
type ServiceEntry = { id: string; name: string };
export const getInterfaceIp = (option: InterfaceWithIpAddresses): string | undefined => {
const addresses = (option?.ip_addresses ?? []).filter((ip: string) => typeof ip === 'string');
const isIpv6 = (ip: string) => ip.includes(':');
const isIpv6LinkLocal = (ip: string) => ip.toLowerCase().startsWith('fe80:');
@ -264,34 +298,6 @@ export const getInterfaceIp = (option: any) => {
return ipv6NoZone || addresses[0];
};
export const getIpList = (interfaces: InstallInterface[]) =>
Object.values(interfaces)
.reduce(
(acc: string[], curr: InstallInterface) => acc.concat(curr.ip_addresses),
[] as string[],
)
.sort();
/**
* @param {string} ip
* @param {number} [port]
* @returns {string}
*/
export const getDnsAddress = (ip: any, port = 0) => {
const isStandardDnsPort = port === STANDARD_DNS_PORT;
let address = ip;
if (port) {
if (ip.includes(':') && !isStandardDnsPort) {
address = `[${ip}]:${port}`;
} else if (!isStandardDnsPort) {
address = `${ip}:${port}`;
}
}
return address;
};
const normalizeHost = (host: string) => {
const isIpv6 = host.includes(':');
if (!isIpv6) {
@ -313,7 +319,7 @@ const normalizeHost = (host: string) => {
* @param {number} [port]
* @returns {string}
*/
export const getWebAddress = (ip: any, port = 0) => {
export const getWebAddress = (ip: string, port: number = 0): string => {
const isStandardWebPort = port === STANDARD_WEB_PORT;
const rawHost = String(ip);
@ -323,7 +329,7 @@ export const getWebAddress = (ip: any, port = 0) => {
return `http://${host}${portPart}`;
};
export const checkRedirect = (url: any, attempts: number = 1) => {
export const checkRedirect = (url: string, attempts: number = 1): boolean => {
let count = attempts || 1;
if (count > 10) {
@ -331,11 +337,13 @@ export const checkRedirect = (url: any, attempts: number = 1) => {
return false;
}
const rmTimeout = (t: any) => t && clearTimeout(t);
const setRecursiveTimeout = (time: any, ...args: any[]) =>
setTimeout(checkRedirect, time, ...args);
const rmTimeout = (t: ReturnType<typeof setTimeout> | undefined) => t && clearTimeout(t);
const setRecursiveTimeout = (
time: number,
...args: [string, number]
): ReturnType<typeof setTimeout> => setTimeout(checkRedirect, time, ...args);
let timeout: any;
let timeout: ReturnType<typeof setTimeout> | undefined;
fetch(url)
.then((response) => {
@ -354,7 +362,13 @@ export const checkRedirect = (url: any, attempts: number = 1) => {
return false;
};
export const redirectToCurrentProtocol = (values: any, httpPort = 80) => {
type RedirectValues = {
enabled?: boolean;
force_https?: boolean;
port_https?: number;
};
export const redirectToCurrentProtocol = (values: RedirectValues, httpPort = 80) => {
const { protocol, hostname, hash, port } = window.location;
const { enabled, force_https, port_https } = values;
const httpsPort = port_https !== STANDARD_HTTPS_PORT ? `:${port_https}` : '';
@ -377,36 +391,21 @@ export const redirectToCurrentProtocol = (values: any, httpPort = 80) => {
* @param {string} text
* @returns []string
*/
export const splitByNewLine = (text: any) => {
export const splitByNewLine = (text: string | undefined | null): string[] => {
if (!text) {
return [];
}
return text.split('\n').filter((n: any) => n.trim());
return text.split('\n').filter((n: string) => n.trim());
};
/**
* @param {string} text
* @returns {string}
*/
export const trimMultilineString = (text: any) =>
splitByNewLine(text)
.map((line: any) => line.trim())
.join('\n');
/**
* @param {string} text
* @returns {string}
*/
export const removeEmptyLines = (text: any) => splitByNewLine(text).join('\n');
/**
* @param {string} input
* @returns {string}
*/
export const trimLinesAndRemoveEmpty = (input: any) =>
export const trimLinesAndRemoveEmpty = (input: string): string =>
input
.split('\n')
.map((line: any) => line.trim())
.map((line: string) => line.trim())
.filter(Boolean)
.join('\n');
@ -422,9 +421,14 @@ export const trimLinesAndRemoveEmpty = (input: any) =>
* @returns {Object.<string, number>} normalizedTopClients.auto - auto clients
* @returns {Object.<string, number>} normalizedTopClients.configured - configured clients
*/
export const normalizeTopClients = (topClients: any) =>
export const normalizeTopClients = (
topClients: (TopStat & { info: ClientFindSubEntry })[],
): { auto: Record<string, number>; configured: Record<string, number> } =>
topClients.reduce(
(acc: any, clientObj: any) => {
(
acc: { auto: Record<string, number>; configured: Record<string, number> },
clientObj: TopStat & { info: ClientFindSubEntry },
) => {
const {
name,
count,
@ -440,35 +444,14 @@ export const normalizeTopClients = (topClients: any) =>
},
);
export const sortClients = (clients: any) => {
const compare = (a: any, b: any) => {
const nameA = a.name.toUpperCase();
const nameB = b.name.toUpperCase();
export const msToSeconds = (milliseconds: number): number => Math.floor(milliseconds / 1000);
if (nameA > nameB) {
return 1;
}
if (nameA < nameB) {
return -1;
}
export const msToMinutes = (milliseconds: number): number => Math.floor(milliseconds / 1000 / 60);
return 0;
};
export const msToHours = (milliseconds: number): number =>
Math.floor(milliseconds / 1000 / 60 / 60);
return clients.sort(compare);
};
export const toggleAllServices = (services: any, change: any, isSelected: any) => {
services.forEach((service: any) => change(`blocked_services.${service.id}`, isSelected));
};
export const msToSeconds = (milliseconds: any) => Math.floor(milliseconds / 1000);
export const msToMinutes = (milliseconds: any) => Math.floor(milliseconds / 1000 / 60);
export const msToHours = (milliseconds: any) => Math.floor(milliseconds / 1000 / 60 / 60);
export const secondsToMilliseconds = (seconds: any) => {
export const secondsToMilliseconds = (seconds: number): number => {
if (seconds) {
return seconds * 1000;
}
@ -476,12 +459,12 @@ export const secondsToMilliseconds = (seconds: any) => {
return seconds;
};
export const msToDays = (milliseconds: any) => Math.floor(milliseconds / 1000 / 60 / 60 / 24);
export const normalizeRulesTextarea = (text: any) =>
export const normalizeRulesTextarea = (text: string): string | undefined =>
text?.replace(/^\n/g, '').replace(/\n\s*\n/g, '\n');
export const normalizeWhois = (whois: any) => {
export const normalizeWhois = (
whois: QueryLogItemClientWhois,
): Partial<QueryLogItemClientWhois> & { location?: string } => {
if (Object.keys(whois).length > 0) {
const { city, country, ...values } = whois;
let location = country || '';
@ -508,7 +491,10 @@ export const normalizeWhois = (whois: any) => {
};
};
export const getPathWithQueryString = (path: any, params: any) => {
export const getPathWithQueryString = (
path: string,
params: Record<string, string | string[] | undefined | null> | undefined,
): string => {
const searchParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
@ -530,12 +516,16 @@ export const getPathWithQueryString = (path: any, params: any) => {
return `${path}?${searchParams.toString()}`;
};
export const getParamsForClientsSearch = (data: any, param: any, additionalParam?: any) => {
const clients = new Set();
data.forEach((e: any) => {
clients.add(e[param]);
if (e[additionalParam]) {
clients.add(e[additionalParam]);
export const getParamsForClientsSearch = (
data: Record<string, unknown>[],
param: string,
additionalParam?: string,
): { clients: { id: string }[] } => {
const clients = new Set<string | number>();
data.forEach((e: Record<string, unknown>) => {
clients.add(e[param] as string | number);
if (e[additionalParam as string]) {
clients.add(e[additionalParam as string] as string | number);
}
});
@ -544,67 +534,10 @@ export const getParamsForClientsSearch = (data: any, param: any, additionalParam
};
};
/**
* Creates onBlur handler that can normalize input if normalization function is specified
*
* @param {Object} event
* @param {Object} event.target
* @param {string} event.target.value
* @param {Object} input
* @param {function} input.onBlur
* @param {function} [normalizeOnBlur]
* @returns {function}
*/
export const checkFiltered = (reason: any) => reason.indexOf(FILTERED) === 0;
export const checkRewrite = (reason: any) => reason === FILTERED_STATUS.REWRITE;
export const checkRewriteHosts = (reason: any) => reason === FILTERED_STATUS.REWRITE_HOSTS;
export const checkBlackList = (reason: any) => reason === FILTERED_STATUS.FILTERED_BLACK_LIST;
export const checkWhiteList = (reason: any) => reason === FILTERED_STATUS.NOT_FILTERED_WHITE_LIST;
// eslint-disable-next-line max-len
export const checkNotFilteredNotFound = (reason: any) =>
reason === FILTERED_STATUS.NOT_FILTERED_NOT_FOUND;
export const checkSafeSearch = (reason: any) => reason === FILTERED_STATUS.FILTERED_SAFE_SEARCH;
export const checkSafeBrowsing = (reason: any) => reason === FILTERED_STATUS.FILTERED_SAFE_BROWSING;
export const checkParental = (reason: any) => reason === FILTERED_STATUS.FILTERED_PARENTAL;
export const checkBlockedService = (reason: any) =>
export const checkFiltered = (reason: FilteringReason): boolean => reason.indexOf(FILTERED) === 0;
export const checkBlockedService = (reason: FilteringReason): boolean =>
reason === FILTERED_STATUS.FILTERED_BLOCKED_SERVICE;
export const getCurrentFilter = (url: any, filters: any) => {
const filter = filters?.find((item: any) => url === item.url);
if (filter) {
const { enabled, name, url } = filter;
return {
enabled,
name,
url,
};
}
return {
enabled: true,
name: '',
url: '',
};
};
/**
* @param {object} initialValues
* @param {object} values
* @returns {object} Returns different values of objects
*/
export const getObjDiff = (initialValues: any, values: any) =>
Object.entries(values)
.reduce((acc: any, [key, value]) => {
if (value !== initialValues[key]) {
acc[key] = value;
}
return acc;
}, {});
/**
* @param num {number} to format
* @returns {string} Returns a string with a language-sensitive representation of this number
@ -641,24 +574,12 @@ export const formatCompactNumber = (num: number, decimals: number = 1): string =
return sign + formatted + suffix;
};
/**
* @param arr {array}
* @param key {string}
* @param value {string}
* @returns {object}
*/
export const getMap = (arr: any, key: any, value: any) =>
arr.reduce((acc: any, curr: any) => {
acc[curr[key]] = curr[value];
return acc;
}, {});
/**
* @param parsedIp {object} ipaddr.js IPv4 or IPv6 object
* @param parsedCidr {array} ipaddr.js CIDR array
* @returns {boolean}
*/
const isIpMatchCidr = (parsedIp: any, parsedCidr: any) => {
const isIpMatchCidr = (parsedIp: IPv4 | IPv6, parsedCidr: [IPv4 | IPv6, number]): boolean => {
try {
const cidrIpVersion = parsedCidr[0].kind();
const ipVersion = parsedIp.kind();
@ -669,7 +590,7 @@ const isIpMatchCidr = (parsedIp: any, parsedCidr: any) => {
}
};
export const isIpInCidr = (ip: any, cidr: any) => {
export const isIpInCidr = (ip: string, cidr: string): boolean => {
try {
const parsedIp = ipaddr.parse(ip);
const parsedCidr = ipaddr.parseCIDR(cidr);
@ -699,7 +620,7 @@ export const isValidIpv6 = (value: string): boolean => {
* @param {string} subnetMask
* @returns {IPv4 | null}
*/
export const parseSubnetMask = (subnetMask: any) => {
export const parseSubnetMask = (subnetMask: string): number | null => {
try {
return ipaddr.parse(subnetMask).prefixLengthFromSubnetMask();
} catch (e) {
@ -713,8 +634,10 @@ export const parseSubnetMask = (subnetMask: any) => {
* @param {string} subnetMask
* @returns {*}
*/
export const subnetMaskToBitMask = (subnetMask: any) =>
subnetMask.split('.').reduce((acc: any, cur: any) => acc - Math.log2(256 - Number(cur)), 32);
export const subnetMaskToBitMask = (subnetMask: string): number =>
subnetMask
.split('.')
.reduce((acc: number, cur: string) => acc - Math.log2(256 - Number(cur)), 32);
/**
*
@ -722,7 +645,7 @@ export const subnetMaskToBitMask = (subnetMask: any) =>
* @returns {'IP' | 'CIDR' | 'CLIENT_ID' | 'UNKNOWN'}
*
*/
export const findAddressType = (address: any) => {
export const findAddressType = (address: string): string => {
try {
const cidrMaybe = address.includes('/');
@ -746,9 +669,11 @@ export const findAddressType = (address: any) => {
* @param ids {string[]}
* @returns {Object}
*/
export const separateIpsAndCidrs = (ids: any) =>
export const separateIpsAndCidrs = (
ids: string[],
): { ips: string[]; cidrs: string[]; clientIds: string[] } =>
ids.reduce(
(acc: any, curr: any) => {
(acc: { ips: string[]; cidrs: string[]; clientIds: string[] }, curr: string) => {
const addressType = findAddressType(curr);
if (addressType === ADDRESS_TYPES.IP) {
@ -765,25 +690,28 @@ export const separateIpsAndCidrs = (ids: any) =>
{ ips: [], cidrs: [], clientIds: [] },
);
export const countClientsStatistics = (ids: any, autoClients: any) => {
export const countClientsStatistics = (
ids: string[],
autoClients: Record<string, number>,
): number => {
const { ips, cidrs, clientIds } = separateIpsAndCidrs(ids);
const ipsCount = ips.reduce((acc: any, curr: any) => {
const ipsCount = ips.reduce((acc: number, curr: string) => {
const count = autoClients[curr] || 0;
return acc + count;
}, 0);
const clientIdsCount = clientIds.reduce((acc: any, curr: any) => {
const clientIdsCount = clientIds.reduce((acc: number, curr: string) => {
const count = autoClients[curr] || 0;
return acc + count;
}, 0);
const cidrsCount = Object.entries(autoClients).reduce((acc: any, curr: any) => {
const cidrsCount = Object.entries(autoClients).reduce((acc: number, curr: [string, number]) => {
const [id, count] = curr;
if (!ipaddr.isValid(id)) {
return acc;
}
if (cidrs.some((cidr: any) => isIpInCidr(id, cidr))) {
if (cidrs.some((cidr: string) => isIpInCidr(id, cidr))) {
// eslint-disable-next-line no-param-reassign
acc += count;
}
@ -814,7 +742,7 @@ export const formatElapsedMs = (elapsedMs: string, millisecondsLabel: string) =>
/**
* @param language {string}
*/
export const setHtmlLangAttr = (language: any) => {
export const setHtmlLangAttr = (language: string): void => {
window.document.documentElement.lang = language;
};
@ -823,7 +751,7 @@ export const setHtmlLangAttr = (language: any) => {
*
* @param {string} theme
*/
export const setTheme = (theme: any) => {
export const setTheme = (theme: string): void => {
LocalStorageHelper.setItem(LOCAL_STORAGE_KEYS.THEME, theme);
};
@ -833,14 +761,15 @@ export const setTheme = (theme: any) => {
* @returns {string}
*/
export const getTheme = () => LocalStorageHelper.getItem(LOCAL_STORAGE_KEYS.THEME) || THEMES.light;
export const getTheme = () =>
LocalStorageHelper.getItem<string>(LOCAL_STORAGE_KEYS.THEME) || THEMES.light;
/**
* Sets UI theme.
*
* @param theme
*/
export const setUITheme = (theme: any) => {
export const setUITheme = (theme?: string): void => {
let currentTheme = theme || getTheme();
if (currentTheme === THEMES.auto) {
@ -853,25 +782,6 @@ export const setUITheme = (theme: any) => {
document.documentElement.style.colorScheme = currentTheme;
};
/**
* @param values {object}
* @returns {object}
*/
export const replaceEmptyStringsWithZeroes = (values: any) =>
Object.entries(values)
.reduce((acc: any, [key, value]) => {
acc[key] = value === '' ? 0 : value;
return acc;
}, {});
/**
* @param value {number || string}
* @returns {string}
*/
export const replaceZeroWithEmptyString = (value: any) => (parseInt(value, 10) === 0 ? '' : value);
/**
* @param {string} search
* @param {string} status
@ -885,34 +795,11 @@ export const getLogsUrlParams = (search: string, status: string, reason: string)
reason: reason || undefined,
})}`;
export const processContent = (content: any) =>
Array.isArray(content)
? content.filter(([, value]) => value).reduce((acc, val) => acc.concat(val), [])
: content;
// TODO check getObjectKeysSorted
type NestedObject = {
[key: string]: any;
order: number;
};
export const getObjectKeysSorted = <
T extends Record<string, NestedObject>,
K extends keyof NestedObject,
>(
object: T,
sortKey: K,
): string[] => {
return Object.entries(object)
.sort(([, a], [, b]) => (a[sortKey] as number) - (b[sortKey] as number))
.map(([key]) => key);
};
/**
* @param ip
* @returns {[IPv4|IPv6, 33|129]}
*/
const getParsedIpWithPrefixLength = (ip: any) => {
const getParsedIpWithPrefixLength = (ip: string): [IPv4 | IPv6, number] => {
const MAX_PREFIX_LENGTH_V4 = 32;
const MAX_PREFIX_LENGTH_V6 = 128;
@ -928,7 +815,7 @@ const getParsedIpWithPrefixLength = (ip: any) => {
* @param item - ip or cidr
* @returns {number[]}
*/
const getAddressesComparisonBytes = (item: any) => {
const getAddressesComparisonBytes = (item: string): number[] => {
// Sort ipv4 before ipv6
const IP_V4_COMPARISON_CODE = 0;
const IP_V6_COMPARISON_CODE = 1;
@ -979,7 +866,7 @@ export const sortIp = (a: string, b: string): number => {
* @param {number} filterId
* @returns {string}
*/
export const getSpecialFilterName = (filterId: any) => {
export const getSpecialFilterName = (filterId: number): string => {
switch (filterId) {
case SPECIAL_FILTER_ID.CUSTOM_FILTERING_RULES:
return intl.getMessage('custom_rules');
@ -1030,97 +917,10 @@ export const getFilterName = (
};
export const getFilterNames = (rules: Rule[], filters: Filter[], whitelistFilters: Filter[]) =>
rules.map(({ filter_list_id }: any) =>
rules.map(({ filter_list_id }: Rule) =>
getFilterName(filters, whitelistFilters, filter_list_id),
);
export const getRuleNames = (rules: Rule[]) => rules.map(({ text }: Rule) => text);
export const getFilterNameToRulesMap = (
rules: Rule[],
filters: Filter[],
whitelistFilters: Filter[],
) =>
rules.reduce((acc: any, { text, filter_list_id }: Rule) => {
const filterName = getFilterName(filters, whitelistFilters, filter_list_id);
acc[filterName] = (acc[filterName] || []).concat(text);
return acc;
}, {});
export const getRulesToFilterList = (
rules: Rule[],
filters: Filter[],
whitelistFilters: Filter[],
classes = {
list: 'filteringRules',
rule: 'filteringRules__rule font-monospace',
filter: 'filteringRules__filter',
},
) => {
const filterNameToRulesMap: { string: string[] } = getFilterNameToRulesMap(
rules,
filters,
whitelistFilters,
);
return (
<dl class={classes.list}>
{Object.entries(filterNameToRulesMap).reduce(
(acc: any, [filterName, rulesArr]) =>
acc
.concat(
rulesArr.map((rule: any, _i: any) => (
<dd class={classes.rule}>{rule}</dd>
)),
)
.concat(<dt class={classes.filter}>{filterName}</dt>),
[],
)}
</dl>
);
};
/**
* @param ip {string}
* @param gateway_ip {string}
* @returns {{range_end: string, subnet_mask: string, range_start: string,
* lease_duration: string, gateway_ip: string}}
*/
export const calculateDhcpPlaceholdersIpv4 = (ip: string, gateway_ip: string) => {
const LAST_OCTET_IDX = 3;
const LAST_OCTET_RANGE_START = 100;
const LAST_OCTET_RANGE_END = 200;
const addr = ipaddr.parse(ip) as IPv4;
addr.octets[LAST_OCTET_IDX] = LAST_OCTET_RANGE_START;
const range_start = addr.toString();
addr.octets[LAST_OCTET_IDX] = LAST_OCTET_RANGE_END;
const range_end = addr.toString();
const { subnet_mask, lease_duration } = DHCP_VALUES_PLACEHOLDERS.ipv4;
return {
gateway_ip: gateway_ip || ip,
subnet_mask,
range_start,
range_end,
lease_duration,
};
};
export const calculateDhcpPlaceholdersIpv6 = () => {
const { range_start, range_end, lease_duration } = DHCP_VALUES_PLACEHOLDERS.ipv6;
return {
range_start,
range_end,
lease_duration,
};
};
/**
* Add ip_addresses property - concatenated ipv4_addresses and ipv6_addresses for every interface
* @param interfaces
@ -1138,61 +938,73 @@ export const enrichWithConcatenatedIpAddresses = (interfaces: NetInterfaces): Dh
return acc;
}, {});
export const isScrolledIntoView = (el: any) => {
const rect = el.getBoundingClientRect();
const elemTop = rect.top;
const elemBottom = rect.bottom;
return elemTop < window.innerHeight && elemBottom >= 0;
};
/**
* If this is a manually created client, return its name.
* If this is a "runtime" client, return it's IP address.
* @param clients {Array.<object>}
* @param ip {string}
* @returns {string}
*/
export const getBlockingClientName = (clients: any, ip: any) => {
for (let i = 0; i < clients.length; i += 1) {
const client = clients[i];
if (client.ids.includes(ip)) {
return client.name;
}
}
return ip;
};
/**
* @param {string[]} lines
* @returns {string[]}
*/
export const filterOutComments = (lines: any) =>
lines.filter((line: any) => !line.startsWith(COMMENT_LINE_DEFAULT_TOKEN));
export const filterOutComments = (lines: string[]): string[] =>
lines.filter((line: string) => !line.startsWith(COMMENT_LINE_DEFAULT_TOKEN));
export const isCommentLine = (line: string) => /^\s*[#!]/.test(line);
/**
* Computes DHCP v4 placeholder values from the interface IP address.
* Replaces the last octet with 100 for range_start and 200 for range_end.
* @param ip - The interface's IPv4 address (e.g. "192.168.1.1")
* @param gatewayIp - The interface's gateway IP (falls back to `ip` if empty)
* @returns Pre-filled v4 config values
*/
export const calculateDhcpPlaceholdersIpv4 = (ip: string, gatewayIp: string) => {
const LAST_OCTET_IDX = 3;
const LAST_OCTET_RANGE_START = 100;
const LAST_OCTET_RANGE_END = 200;
const addr = ipaddr.parse(ip) as IPv4;
addr.octets[LAST_OCTET_IDX] = LAST_OCTET_RANGE_START;
const range_start = addr.toString();
addr.octets[LAST_OCTET_IDX] = LAST_OCTET_RANGE_END;
const range_end = addr.toString();
const { subnet_mask, lease_duration } = DHCP_VALUES_PLACEHOLDERS.ipv4;
return {
gateway_ip: gatewayIp || ip,
subnet_mask,
range_start,
range_end,
lease_duration,
};
};
/**
* Computes DHCP v6 placeholder values (static defaults).
* @returns Pre-filled v6 config values
*/
export const calculateDhcpPlaceholdersIpv6 = () => {
const { range_start, range_end, lease_duration } = DHCP_VALUES_PLACEHOLDERS.ipv6;
return {
range_start,
range_end,
lease_duration,
};
};
/**
* @param {array} services
* @param {string} id
* @returns {string}
*/
export const getService = (services: any, id: any) => services.find((s: any) => s.id === id);
export const getService = (services: ServiceEntry[], id: string): ServiceEntry | undefined =>
services.find((s: ServiceEntry) => s.id === id);
/**
* @param {array} services
* @param {string} id
* @returns {string}
*/
export const getServiceName = (services: any, id: any) => getService(services, id)?.name;
/**
* @param {array} services
* @param {string} id
* @returns {string}
*/
export const getServiceIcon = (services: any, id: any) => getService(services, id)?.icon_svg;
export const getServiceName = (services: ServiceEntry[], id: string): string | undefined =>
getService(services, id)?.name;
/**
* Decodes a base64-encoded SVG string. Returns an empty string on failure.

View file

@ -9,29 +9,29 @@ export const LOCAL_STORAGE_KEYS = {
};
export const LocalStorageHelper = {
setItem(key: any, value: any) {
setItem(key: string, value: unknown) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error(`Error setting ${key} in local storage: ${error.message}`);
console.error(`Error setting ${key} in local storage: ${(error as Error).message}`);
}
},
getItem(key: any) {
getItem<T = unknown>(key: string): T | null {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : null;
return item ? (JSON.parse(item) as T) : null;
} catch (error) {
console.error(`Error getting ${key} from local storage: ${error.message}`);
console.error(`Error getting ${key} from local storage: ${(error as Error).message}`);
return null;
}
},
removeItem(key: any) {
removeItem(key: string) {
try {
localStorage.removeItem(key);
} catch (error) {
console.error(`Error removing ${key} from local storage: ${error.message}`);
console.error(`Error removing ${key} from local storage: ${(error as Error).message}`);
}
},
@ -39,7 +39,7 @@ export const LocalStorageHelper = {
try {
localStorage.clear();
} catch (error) {
console.error(`Error clearing local storage: ${error.message}`);
console.error(`Error clearing local storage: ${(error as Error).message}`);
}
},
};

View file

@ -1,23 +1,31 @@
import { Show } from 'solid-js';
import type { JSXElement } from 'solid-js';
import { A } from '@solidjs/router';
import { normalizeWhois } from './helpers';
import { WHOIS_ICONS } from './constants';
import type { QueryLogItemClientWhois } from 'panel/api/model/queryLogItemClientWhois';
const getFormattedWhois = (whois: any) => {
type ClientCellInfo = {
name?: string;
whois_info?: QueryLogItemClientWhois;
};
const getFormattedWhois = (whois: QueryLogItemClientWhois) => {
const whoisInfo = normalizeWhois(whois);
return Object.keys(whoisInfo).map((key) => {
return Object.entries(whoisInfo).map(([key, value]) => {
const icon = WHOIS_ICONS[key as keyof typeof WHOIS_ICONS];
const strValue = String(value ?? '');
return (
<span class="logs__whois text-muted" title={whoisInfo[key]}>
<span class="logs__whois text-muted" title={strValue}>
<Show when={icon}>
<svg class="logs__whois-icon icons icon--18">
<use href={`#${icon}`} />
</svg>
&nbsp;
</Show>
{whoisInfo[key]}
{strValue}
</span>
);
});
@ -33,13 +41,13 @@ const getFormattedWhois = (whois: any) => {
* @returns {JSXElement}
*/
export const renderFormattedClientCell = (
value: any,
info: any,
value: string,
info: ClientCellInfo | null,
isDetailed = false,
isLogs = false,
) => {
let whoisContainer = null;
let nameContainer: any = value;
let whoisContainer: JSXElement = null;
let nameContainer: JSXElement = value;
if (info) {
const { name, whois_info } = info;

View file

@ -1,19 +0,0 @@
import { createSignal, createEffect, onCleanup } from 'solid-js';
const useDebounce = (value: any, delay: any) => {
const [debouncedValue, setDebouncedValue] = createSignal(value);
createEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
onCleanup(() => {
clearTimeout(handler);
});
});
return [debouncedValue, setDebouncedValue];
};
export default useDebounce;

View file

@ -6,7 +6,7 @@
* @param right {string} - right version
* @return {boolean} true if versions are equal
*/
export const areEqualVersions = (left: any, right: any) => {
export const areEqualVersions = (left: string, right: string): boolean => {
if (!left || !right) {
return false;
}

View file

@ -9,8 +9,29 @@ import {
TIME_UNITS,
} from './helpers/constants';
import { DEFAULT_BLOCKING_IPV4, DEFAULT_BLOCKING_IPV6 } from './stores/dnsConfig';
import { Filter } from './helpers/helpers';
import { SafeSearchConfig } from './api/model/safeSearchConfig';
import { Filter, type NormalizedQueryLogItem } from './helpers/helpers';
import type { WhoisInfo } from './api/model/whoisInfo';
import type { ClientAuto as AutoClient } from './api/model/clientAuto';
import type { Client } from './api/model/client';
import type { NetInterface } from './api/model/netInterface';
import type { NetInterfaces } from './api/model/netInterfaces';
import type { TlsConfig } from './api/model/tlsConfig';
import type { TlsConfigKeyType } from './api/model/tlsConfigKeyType';
import type { DnsInfo200 } from './api/model/dnsInfo200';
import type { DNSConfigBlockingMode, DNSConfigUpstreamMode } from './api/model';
import type { FilterStatus } from './api/model/filterStatus';
import type { DhcpStaticLease } from './api/model/dhcpStaticLease';
import type { DhcpSearchResult } from './api/model/dhcpSearchResult';
import type { Stats } from './api/model/stats';
import type { GetStatsConfigResponse } from './api/model/getStatsConfigResponse';
import type { GetQueryLogConfigResponse } from './api/model/getQueryLogConfigResponse';
import type { RewriteEntry } from './api/model/rewriteEntry';
import type { RewriteSettings } from './api/model/rewriteSettings';
import type { BlockedServicesSchedule } from './api/model/blockedServicesSchedule';
import type { BlockedService } from './api/model/blockedService';
import type { ServiceGroup } from './api/model/serviceGroup';
import type { QueryLogFilter } from './helpers/constants';
import type { ToastNotice } from './stores/toasts';
export type InstallInterface = {
flags: string;
@ -52,77 +73,26 @@ export type InstallData = {
dnsVersion: string;
};
export type EncryptionData = {
export type EncryptionData = Partial<
Omit<TlsConfig, 'port_https' | 'port_dns_over_tls' | 'port_dns_over_quic' | 'dns_names'>
> & {
// UI-only fields NOT in API model:
processing: boolean;
processingConfig: boolean;
processingValidate: boolean;
enabled: boolean;
serve_plain_dns: boolean;
dns_names: any;
force_https: boolean;
issuer: string;
key_type: string;
not_after: string;
not_before: string;
port_dns_over_tls?: number;
port_dns_over_quic?: number;
port_https?: number;
port_dnscrypt?: number;
subject: string;
valid_chain: boolean;
valid_key: boolean;
valid_cert: boolean;
valid_pair: boolean;
status_cert: string;
status_key: string;
private_key: string;
server_name: string;
warning_validation: string;
certificate_chain: string;
certificate_path: string;
private_key_path: string;
private_key_saved: boolean;
allow_unencrypted_doh?: boolean;
dnscrypt_config_file?: string;
status_cert: string; // UI concatenation
status_key: string; // UI concatenation
allow_unencrypted_doh: boolean;
// Port fields: number from API, string from form input (initialized as ''):
port_https: number | string;
port_dns_over_tls: number | string;
port_dns_over_quic: number | string;
port_dnscrypt: number | string;
// Store initializes as null, API returns string[]:
dns_names: string[] | null;
};
export type Client = {
blocked_services: string[];
blocked_services_schedule: {
sun?: { start: number; end: number };
mon?: { start: number; end: number };
tue?: { start: number; end: number };
wed?: { start: number; end: number };
thu?: { start: number; end: number };
fri?: { start: number; end: number };
sat?: { start: number; end: number };
time_zone: string;
};
filtering_enabled: boolean;
ids: string[];
ignore_querylog: boolean;
ignore_statistics: boolean;
name: string;
parental_enabled: boolean;
safe_search: SafeSearchConfig;
safebrowsing_enabled: boolean;
safesearch_enabled: boolean;
tags: string[];
upstreams: string[];
upstreams_cache_enabled: boolean;
upstreams_cache_size: number;
use_global_blocked_services: boolean;
use_global_settings: boolean;
};
export type WhoisInfo = Record<string, string>;
export type AutoClient = {
ip: string;
name: string;
source: string;
whois_info: WhoisInfo;
};
export { type WhoisInfo, type AutoClient, type Client };
export type DashboardData = {
processing: boolean;
@ -132,7 +102,7 @@ export type DashboardData = {
processingUpdate: boolean;
processingProfile: boolean;
protectionEnabled: boolean;
protectionDisabledDuration: any;
protectionDisabledDuration: number | null;
protectionCountdownActive: boolean;
processingProtection: boolean;
httpPort: number;
@ -167,7 +137,7 @@ export type SettingsData = {
};
};
export type RewritesData = {
export type RewritesData = RewriteSettings & {
processing: boolean;
processingAdd: boolean;
processingDelete: boolean;
@ -175,17 +145,8 @@ export type RewritesData = {
processingSettings: boolean;
isModalOpen: boolean;
modalType: string;
currentRewrite?: {
answer: string;
domain: string;
enabled: boolean;
};
list: {
answer: string;
domain: string;
enabled: boolean;
}[];
enabled: boolean;
currentRewrite?: RewriteEntry;
list: RewriteEntry[];
};
export type NormalizedTopClients = {
@ -193,37 +154,31 @@ export type NormalizedTopClients = {
configured: Record<string, number>;
};
export type StatsData = {
processingGetConfig: boolean;
processingSetConfig: boolean;
processingStats: boolean;
processingReset: boolean;
interval: number;
customInterval?: number;
dnsQueries: number[];
blockedFiltering: number[];
replacedParental: number[];
replacedSafebrowsing: number[];
topBlockedDomains: { name: string; count: number }[];
topClients: {
name: string;
count: number;
info: any;
}[];
normalizedTopClients?: NormalizedTopClients;
topQueriedDomains: { name: string; count: number }[];
numBlockedFiltering: number;
numDnsQueries: number;
numReplacedParental: number;
numReplacedSafebrowsing: number;
numReplacedSafesearch: number;
avgProcessingTime: number;
timeUnits: string;
enabled: boolean;
topUpstreamsAvgTime: { name: string; count: number }[];
topUpstreamsResponses: { name: string; count: number }[];
ignored: string[];
};
export type StatsData = Omit<
Stats,
| 'top_queried_domains'
| 'top_clients'
| 'top_blocked_domains'
| 'top_upstreams_responses'
| 'top_upstreams_avg_time'
| 'time_units'
> &
Omit<GetStatsConfigResponse, 'interval'> & {
processingGetConfig: boolean;
processingSetConfig: boolean;
processingStats: boolean;
processingReset: boolean;
interval: number;
customInterval?: number | null;
// Normalized top stats (from normalizeTopStats):
topBlockedDomains: { name: string; count: number }[];
topClients: { name: string; count: number; info: string }[]; // info is string!
topQueriedDomains: { name: string; count: number }[];
topUpstreamsAvgTime: { name: string; count: number }[];
topUpstreamsResponses: { name: string; count: number }[];
normalizedTopClients?: NormalizedTopClients;
timeUnits: string;
};
export type ClientsData = {
processing: boolean;
@ -243,16 +198,7 @@ export type AccessData = {
blocked_hosts: string;
};
export type DhcpInterface = {
name: string;
flags: string;
gateway_ip: string;
ip_addresses: string[];
ipv4_addresses: string[];
ipv6_addresses: string[];
hardware_address: string;
};
export type DhcpInterface = NetInterface & { ip_addresses: string[] };
export type DhcpInterfaces = Record<string, DhcpInterface>;
export type DhcpData = {
@ -266,16 +212,9 @@ export type DhcpData = {
processingUpdating: boolean;
enabled: boolean;
interface_name: string;
check?: {
v4?: {
other_server?: { found: string; error?: string };
static_ip?: { static: string; ip: string };
};
v6?: {
other_server?: { found: string; error?: string };
static_ip?: { static: string; ip: string };
};
};
// Use generated DhcpSearchResult:
check: DhcpSearchResult | null;
// Keep inline v4/v6 (required — always present after init):
v4: {
gateway_ip: string;
subnet_mask: string;
@ -287,61 +226,43 @@ export type DhcpData = {
range_start: string;
lease_duration: number;
};
leases: {
hostname: string;
ip: string;
mac: string;
}[];
staticLeases: {
hostname: string;
ip: string;
mac: string;
}[];
// UI-normalized leases (flat without expires):
leases: { hostname: string; ip: string; mac: string }[];
staticLeases: DhcpStaticLease[];
isModalOpen: boolean;
leaseModalConfig?: {
hostname: string;
ip: string;
mac: string;
};
leaseModalConfig?: { hostname: string; ip: string; mac: string };
modalType: string;
dhcp_available: boolean;
interfaces?: DhcpInterfaces;
interfaces?: NetInterfaces;
};
export type DnsConfigData = {
export type DnsConfigData = Omit<
DnsInfo200,
| 'upstream_dns'
| 'fallback_dns'
| 'bootstrap_dns'
| 'local_ptr_upstreams'
| 'ratelimit_whitelist'
| 'blocking_mode'
| 'upstream_mode'
| 'protection_enabled'
| 'protection_disabled_until'
> & {
// UI-only processing flags:
processingGetConfig: boolean;
processingSetConfig: boolean;
blocking_mode: string;
ratelimit: number;
blocking_ipv4: string;
blocking_ipv6: string;
blocked_response_ttl: number;
upstream_timeout: number;
edns_cs_enabled: boolean;
disable_ipv6: boolean;
dnssec_enabled: boolean;
upstream_dns_file: string;
// Normalized fields (string[] → newline-joined string):
blocking_mode: DNSConfigBlockingMode;
upstream_mode: DNSConfigUpstreamMode;
upstream_dns: string;
fallback_dns: string;
bootstrap_dns: string;
local_ptr_upstreams: string;
ratelimit_whitelist: string;
upstream_mode: string;
resolve_clients: boolean;
use_private_ptr_resolvers: boolean;
default_local_ptr_upstreams: string[];
ratelimit_subnet_len_ipv4?: number;
ratelimit_subnet_len_ipv6?: number;
edns_cs_use_custom?: boolean;
edns_cs_custom_ip?: string;
cache_size?: number;
cache_ttl_max?: number;
cache_ttl_min?: number;
cache_optimistic?: boolean;
cache_enabled?: boolean;
};
export type FilteringData = {
export type FilteringData = Omit<FilterStatus, 'filters' | 'whitelist_filters' | 'user_rules'> & {
// UI-only fields:
isModalOpen: boolean;
processingFilters: boolean;
processingRules: boolean;
@ -354,42 +275,37 @@ export type FilteringData = {
isFilterAdded: boolean;
isFilterRemoved: boolean;
isFilterEdited: boolean;
filters: Filter[];
whitelistFilters: any[];
userRules: string;
interval: number;
enabled: boolean;
modalType: string;
modalFilterUrl: string;
check: any;
check: Record<string, unknown> | Record<string, never>;
// Normalized fields (camelCase from normalizeFilteringStatus):
filters: Filter[];
whitelistFilters: Filter[]; // Note: whitelist (no underscore) — matches store
userRules: string;
};
export type QueryLogsData = {
export type QueryLogsData = Omit<GetQueryLogConfigResponse, 'interval'> & {
processingGetLogs: boolean;
processingClear: boolean;
processingGetConfig: boolean;
processingSetConfig: boolean;
processingAdditionalLogs: boolean;
interval: any;
logs: any[];
enabled: boolean;
interval: number;
customInterval: number | null;
logs: NormalizedQueryLogItem[];
oldest: string;
filter: any;
filter: QueryLogFilter;
isFiltered: boolean;
anonymize_client_ip: boolean;
isDetailed: boolean;
isEntireLog: boolean;
customInterval: any;
ignored: string[];
};
export type ServicesData = {
export type ServicesData = BlockedServicesSchedule & {
processing: boolean;
processingAll: boolean;
processingSet: boolean;
list: any;
allServices: any[];
allGroups: any[];
allServices: BlockedService[];
allGroups: ServiceGroup[];
};
export type ModalsData = {
@ -414,6 +330,7 @@ export type ClientFormState = {
duckduckgo: boolean;
yandex: boolean;
pixabay: boolean;
ecosia: boolean;
};
ignore_querylog: boolean;
ignore_statistics: boolean;
@ -454,6 +371,7 @@ export const getInitialClientFormState = (): ClientFormState => ({
duckduckgo: false,
yandex: false,
pixabay: false,
ecosia: false,
},
ignore_querylog: false,
ignore_statistics: false,
@ -483,14 +401,14 @@ export type RootState = {
settings?: SettingsData;
stats?: StatsData;
install?: InstallData;
toasts: { notices: any[] };
toasts: { notices: ToastNotice[] };
modals: ModalsData;
clientForm: ClientFormState;
};
export type InstallState = {
install: InstallData;
toasts: { notices: any[] };
toasts: { notices: ToastNotice[] };
};
export type LoginState = {
@ -500,7 +418,7 @@ export type LoginState = {
password: string;
error: unknown;
};
toasts: { notices: any[] };
toasts: { notices: ToastNotice[] };
};
export const initialState: RootState = {
@ -609,7 +527,7 @@ export const initialState: RootState = {
dns_names: null,
force_https: false,
issuer: '',
key_type: '',
key_type: '' as TlsConfigKeyType,
not_after: '',
not_before: '',
subject: '',
@ -619,6 +537,7 @@ export const initialState: RootState = {
valid_pair: false,
status_cert: '',
status_key: '',
allow_unencrypted_doh: false,
certificate_chain: '',
private_key: '',
server_name: '',
@ -626,6 +545,10 @@ export const initialState: RootState = {
certificate_path: '',
private_key_path: '',
private_key_saved: false,
port_https: '',
port_dns_over_tls: '',
port_dns_over_quic: '',
port_dnscrypt: '',
},
filtering: {
isModalOpen: false,
@ -682,10 +605,9 @@ export const initialState: RootState = {
processing: true,
processingAll: true,
processingSet: false,
list: {},
allServices: [],
allGroups: [],
},
} as ServicesData,
settings: {
processing: true,
processingTestUpstream: false,
@ -698,19 +620,19 @@ export const initialState: RootState = {
processingReset: false,
interval: DAY,
customInterval: null,
dnsQueries: [],
blockedFiltering: [],
replacedParental: [],
replacedSafebrowsing: [],
dns_queries: [],
blocked_filtering: [],
replaced_parental: [],
replaced_safebrowsing: [],
topBlockedDomains: [],
topClients: [],
topQueriedDomains: [],
numBlockedFiltering: 0,
numDnsQueries: 0,
numReplacedParental: 0,
numReplacedSafebrowsing: 0,
numReplacedSafesearch: 0,
avgProcessingTime: 0,
num_blocked_filtering: 0,
num_dns_queries: 0,
num_replaced_parental: 0,
num_replaced_safebrowsing: 0,
num_replaced_safesearch: 0,
avg_processing_time: 0,
timeUnits: TIME_UNITS.HOURS,
enabled: true,
topUpstreamsAvgTime: [],

View file

@ -4,6 +4,7 @@ import { accessList, accessSet } from 'panel/api/generated';
import { addErrorToast, addSuccessToast } from './toasts';
import { splitByNewLine } from 'panel/helpers/helpers';
import intl from 'panel/common/intl';
import type { AccessList } from 'panel/api/model/accessList';
type AccessState = {
processing: boolean;
@ -39,20 +40,27 @@ export const getAccessList = async () => {
}
};
export const setAccessList = async (values: any) => {
export const setAccessList = async (values: {
allowed_clients?: string;
disallowed_clients?: string;
blocked_hosts?: string;
}) => {
setState('processingSet', true);
try {
const config = { ...values };
if (Object.hasOwn(config, 'allowed_clients')) {
config.allowed_clients = splitByNewLine(config.allowed_clients);
}
if (Object.hasOwn(config, 'disallowed_clients')) {
config.disallowed_clients = splitByNewLine(config.disallowed_clients);
}
if (Object.hasOwn(config, 'blocked_hosts')) {
config.blocked_hosts = splitByNewLine(config.blocked_hosts);
}
const config: AccessList = {
allowed_clients:
values.allowed_clients !== undefined
? splitByNewLine(values.allowed_clients)
: undefined,
disallowed_clients:
values.disallowed_clients !== undefined
? splitByNewLine(values.disallowed_clients)
: undefined,
blocked_hosts:
values.blocked_hosts !== undefined
? splitByNewLine(values.blocked_hosts)
: undefined,
};
await accessSet(config);
setState({ ...values, processingSet: false });
@ -63,12 +71,6 @@ export const setAccessList = async (values: any) => {
}
};
type AccessList = {
allowed_clients?: string[];
disallowed_clients?: string[];
blocked_hosts?: string[];
};
const addUnique = (items: string[], value: string) =>
items.includes(value) ? items : items.concat(value);
const removeValue = (items: string[], value: string) => items.filter((i) => i !== value);

View file

@ -26,6 +26,7 @@ const getInitialClientFormState = (): ClientFormState => ({
duckduckgo: false,
yandex: false,
pixabay: false,
ecosia: false,
},
ignore_querylog: false,
ignore_statistics: false,
@ -57,18 +58,20 @@ export const initClientForm = (client?: Partial<ClientFormState> | null) => {
};
export const updateClientFormField = (
fieldOrObj: keyof ClientFormState | { field: keyof ClientFormState; value: any },
maybeValue?: any,
fieldOrObj:
| keyof ClientFormState
| { field: keyof ClientFormState; value: ClientFormState[keyof ClientFormState] },
maybeValue?: ClientFormState[keyof ClientFormState],
replace?: boolean,
) => {
const field = typeof fieldOrObj === 'string' ? fieldOrObj : fieldOrObj.field;
const value = typeof fieldOrObj === 'string' ? maybeValue : fieldOrObj.value;
setState(field as keyof ClientFormState, replace ? reconcile(value) : value);
setState(field, replace ? reconcile(value) : value);
// Clear the error for this field
if (state.formErrors[field as string]) {
if (typeof field === 'string' && state.formErrors[field]) {
setState('formErrors', (prev) => {
const next = { ...prev };
delete next[field as string];
delete next[field];
return next;
});
}
@ -98,21 +101,24 @@ export const buildFormPayload = (client: Client): Partial<ClientFormState> => ({
filtering_enabled: client.filtering_enabled || false,
safebrowsing_enabled: client.safebrowsing_enabled || false,
parental_enabled: client.parental_enabled || false,
safe_search: (client.safe_search || {
enabled: false,
google: false,
youtube: false,
bing: false,
duckduckgo: false,
yandex: false,
pixabay: false,
}) as ClientFormState['safe_search'],
safe_search: {
enabled: client.safe_search?.enabled ?? false,
google: client.safe_search?.google ?? false,
youtube: client.safe_search?.youtube ?? false,
bing: client.safe_search?.bing ?? false,
duckduckgo: client.safe_search?.duckduckgo ?? false,
yandex: client.safe_search?.yandex ?? false,
pixabay: client.safe_search?.pixabay ?? false,
ecosia: client.safe_search?.ecosia ?? false,
},
ignore_querylog: client.ignore_querylog || false,
ignore_statistics: client.ignore_statistics || false,
blocked_services: client.blocked_services || [],
use_global_blocked_services: client.use_global_blocked_services || false,
blocked_services_schedule: client.blocked_services_schedule || {
time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone,
blocked_services_schedule: {
time_zone:
client.blocked_services_schedule?.time_zone ??
Intl.DateTimeFormat().resolvedOptions().timeZone,
},
upstreams: (client.upstreams || []).join('\n'),
upstreams_cache_enabled: client.upstreams_cache_enabled || false,
@ -170,14 +176,15 @@ export const saveClient = async (): Promise<boolean> => {
const existingClientIds = computeExistingClientIds();
const idErrors = state.ids.map((id: string, index: number) => {
const idErrors: (string | undefined)[] = state.ids.map((id: string, index: number) => {
if (!id.trim()) {
return intl.getMessage('form_error_required');
}
return validateIdentifier(id, state.ids, index, existingClientIds);
});
if (idErrors.some((e: string | undefined) => e !== undefined)) {
errors.ids = idErrors as string[];
const filteredErrors = idErrors.filter((e): e is string => e !== undefined);
if (filteredErrors.length > 0) {
errors.ids = filteredErrors;
}
// Validate cache size when per-client cache is enabled (and not using global settings).

View file

@ -4,6 +4,7 @@ import { clientsAdd, clientsDelete, clientsUpdate } from 'panel/api/generated';
import { addErrorToast, addSuccessToast } from './toasts';
import intl from 'panel/common/intl';
import { getClients } from './dashboard';
import type { Client } from 'panel/api/model/client';
type ClientsState = {
processing: boolean;
@ -39,7 +40,7 @@ export const toggleClientModal = (payload?: { type?: string; name?: string }) =>
}
};
export const addClient = async (config: any) => {
export const addClient = async (config: Client) => {
setState('processingAdding', true);
try {
await clientsAdd(config);
@ -66,7 +67,7 @@ export const deleteClient = async (name: string) => {
}
};
export const updateClient = async (name: string, data: any): Promise<boolean> => {
export const updateClient = async (name: string, data: Client): Promise<boolean> => {
setState('processingUpdating', true);
try {
await clientsUpdate({ name, data });

View file

@ -16,11 +16,13 @@ import type { ServerStatus } from 'panel/api/model/serverStatus';
import type { VersionInfo } from 'panel/api/model/versionInfo';
import type { Clients } from 'panel/api/model/clients';
import type { ProfileInfo } from 'panel/api/model/profileInfo';
import intl, { LocalesType } from 'panel/common/intl';
import intl from 'panel/common/intl';
import { addErrorToast, addSuccessToast, addNoticeToast } from './toasts';
import { getTlsStatus } from './encryption';
import { getUpdateFailedMessage } from './dashboard/noticeOptions';
import type { Client, AutoClient } from 'panel/initialState';
import type { Client as ClientModel } from 'panel/api/model/client';
import type { ClientAuto } from 'panel/api/model/clientAuto';
type DashboardState = {
processing: boolean;
@ -134,7 +136,7 @@ export const getDnsStatus = async () => {
window.location.reload();
};
const handleRequestSuccess = (response: any) => {
const handleRequestSuccess = (response: { status: number; data: ServerStatus }) => {
const dnsStatus = response.data;
if (dnsStatus.protection_disabled_duration === 0) {
dnsStatus.protection_disabled_duration = null;
@ -174,7 +176,7 @@ export const getTimerStatus = async () => {
window.location.reload();
};
const handleRequestSuccess = (response: any) => {
const handleRequestSuccess = (response: { status: number; data: ServerStatus }) => {
const dnsStatus = response.data;
if (dnsStatus.protection_disabled_duration === 0) {
dnsStatus.protection_disabled_duration = null;
@ -202,7 +204,7 @@ export const getVersion = async (recheck = false) => {
try {
const data: VersionInfo = await getVersionJson({ recheck_now: recheck });
const currentVersion =
untrack(() => state.dnsVersion) === 'undefined' ? 0 : untrack(() => state.dnsVersion);
untrack(() => state.dnsVersion) === 'undefined' ? '' : untrack(() => state.dnsVersion);
if (data && !data.disabled && !areEqualVersions(currentVersion, data.new_version)) {
setState({
announcementUrl: data.announcement_url,
@ -234,7 +236,7 @@ export const getUpdate = async () => {
addNoticeToast(getUpdateFailedMessage());
setState('processingUpdate', false);
};
const handleRequestSuccess = (response: any) => {
const handleRequestSuccess = (response: { status: number; data: ServerStatus }) => {
const responseVersion = response.data?.version;
if (untrack(() => state.dnsVersion) !== responseVersion) {
setState('processingUpdate', false);
@ -271,11 +273,11 @@ export const setDisableDurationTime = (timeToEnableProtection: number) => {
setState('protectionDisabledDuration', timeToEnableProtection);
};
const sortClients = (clients: any[]) => {
const sortClients = (clients: (ClientModel | ClientAuto)[]) => {
if (!Array.isArray(clients)) return [];
return [...clients].sort((a, b) => {
const nameA = (a.name || a.ip || '').toString().toLowerCase();
const nameB = (b.name || b.ip || '').toString().toLowerCase();
const nameA = (a.name || ('ip' in a ? a.ip : '') || '').toString().toLowerCase();
const nameB = (b.name || ('ip' in b ? b.ip : '') || '').toString().toLowerCase();
return nameA.localeCompare(nameB);
});
};
@ -311,10 +313,10 @@ export const getProfileData = async () => {
}
};
export const changeLanguage = async (lang: LocalesType) => {
export const changeLanguage = async (lang: ProfileInfo['language']) => {
try {
const profile = await getProfile();
profile.language = lang as ProfileInfo['language'];
profile.language = lang;
await updateProfile(profile);
setState('language', lang);
} catch (error) {
@ -322,10 +324,10 @@ export const changeLanguage = async (lang: LocalesType) => {
}
};
export const changeTheme = async (theme: string) => {
export const changeTheme = async (theme: ProfileInfo['theme']) => {
try {
const profile = await getProfile();
profile.theme = theme as ProfileInfo['theme'];
profile.theme = theme;
await updateProfile(profile);
setState('theme', theme);
} catch (error) {

View file

@ -17,6 +17,10 @@ import intl from 'panel/common/intl';
import { STATUS_RESPONSE } from 'panel/helpers/constants';
import { Paths } from 'panel/components/Routes/Paths';
import { enrichWithConcatenatedIpAddresses } from 'panel/helpers/helpers';
import type { DhcpStaticLease } from 'panel/api/model/dhcpStaticLease';
import type { DhcpSearchResult } from 'panel/api/model/dhcpSearchResult';
import type { DhcpInterfaces } from 'panel/initialState';
import type { DhcpConfig } from 'panel/api/model/dhcpConfig';
type Lease = { hostname: string; ip: string; mac: string };
@ -34,7 +38,7 @@ type DhcpState = {
processingReset: boolean;
enabled: boolean;
interface_name: string;
check: any;
check: DhcpSearchResult | null;
v4: {
gateway_ip: string;
subnet_mask: string;
@ -47,13 +51,13 @@ type DhcpState = {
lease_duration: number;
};
leases: Lease[];
staticLeases: Lease[];
staticLeases: DhcpStaticLease[];
isModalOpen: boolean;
leaseModalConfig: Lease | undefined;
modalType: LeaseModalType | '';
dhcp_available: boolean;
staticIpError: boolean;
interfaces?: Record<string, any>;
interfaces?: DhcpInterfaces;
};
const initialState: DhcpState = {
@ -228,7 +232,7 @@ export const findActiveDhcp = async (interfaceName: string, navigate?: (path: st
}
};
export const setDhcpConfig = async (values: any) => {
export const setDhcpConfig = async (values: DhcpConfig) => {
setState('processingConfig', true);
try {
await dhcpSetConfig(values);
@ -246,7 +250,7 @@ export const setDhcpConfig = async (values: any) => {
}
};
export const toggleDhcp = async (config?: any) => {
export const toggleDhcp = async (config?: DhcpConfig) => {
setState('processingConfig', true);
try {
const values = config || {};

View file

@ -4,12 +4,13 @@ import { dnsInfo, dnsConfig, cacheClear } from 'panel/api/generated';
import { addErrorToast, addSuccessToast } from './toasts';
import intl from 'panel/common/intl';
import { splitByNewLine } from 'panel/helpers/helpers';
import { DNS_REQUEST_OPTIONS } from 'panel/helpers/constants';
import { DNS_REQUEST_OPTIONS, BLOCKING_MODES } from 'panel/helpers/constants';
import type { DNSConfig, DNSConfigBlockingMode, DNSConfigUpstreamMode } from 'panel/api/model';
type DnsConfigState = {
processingGetConfig: boolean;
processingSetConfig: boolean;
blocking_mode: string;
blocking_mode: DNSConfigBlockingMode;
ratelimit: number;
blocking_ipv4: string;
blocking_ipv6: string;
@ -24,7 +25,7 @@ type DnsConfigState = {
bootstrap_dns: string;
local_ptr_upstreams: string;
ratelimit_whitelist: string;
upstream_mode: string;
upstream_mode: DNSConfigUpstreamMode;
resolve_clients: boolean;
use_private_ptr_resolvers: boolean;
default_local_ptr_upstreams: string[];
@ -41,7 +42,6 @@ type DnsConfigState = {
export const DEFAULT_BLOCKING_IPV4 = '0.0.0.0';
export const DEFAULT_BLOCKING_IPV6 = '::';
const BLOCKING_MODES = { default: 'default' };
const initialState: DnsConfigState = {
processingGetConfig: true,
@ -139,29 +139,41 @@ export const toggleEdnsCsEnabled = () => {
setDnsConfig({ edns_cs_enabled: !state.edns_cs_enabled }, { silent: true });
};
/**
* Splits a newline-delimited string into an array.
* Returns `undefined` when the field wasn't provided (so the backend keeps
* the existing value), as opposed to `[]` (which clears it).
*/
const splitLines = (value: string | undefined): string[] | undefined =>
value !== undefined ? splitByNewLine(value) : undefined;
export const setDnsConfig = async (
values: any,
values: Partial<DnsConfigState>,
opts?: { toastMessage?: string; silent?: boolean },
) => {
setState('processingSetConfig', true);
try {
const config = { ...values };
const {
bootstrap_dns,
fallback_dns,
local_ptr_upstreams,
upstream_dns,
ratelimit_whitelist,
// Strip UI-only fields that are NOT part of DNSConfig
processingGetConfig: _processingGetConfig,
processingSetConfig: _processingSetConfig,
default_local_ptr_upstreams: _defaultLocalPtrUpstreams,
...rest
} = values;
if (Object.hasOwn(config, 'bootstrap_dns')) {
config.bootstrap_dns = splitByNewLine(config.bootstrap_dns);
}
if (Object.hasOwn(config, 'fallback_dns')) {
config.fallback_dns = splitByNewLine(config.fallback_dns);
}
if (Object.hasOwn(config, 'local_ptr_upstreams')) {
config.local_ptr_upstreams = splitByNewLine(config.local_ptr_upstreams);
}
if (Object.hasOwn(config, 'upstream_dns')) {
config.upstream_dns = splitByNewLine(config.upstream_dns);
}
if (Object.hasOwn(config, 'ratelimit_whitelist')) {
config.ratelimit_whitelist = splitByNewLine(config.ratelimit_whitelist);
}
const config: DNSConfig = {
...rest,
bootstrap_dns: splitLines(bootstrap_dns),
fallback_dns: splitLines(fallback_dns),
local_ptr_upstreams: splitLines(local_ptr_upstreams),
upstream_dns: splitLines(upstream_dns),
ratelimit_whitelist: splitLines(ratelimit_whitelist),
};
await dnsConfig(config);
setState(reconcile({ ...untrack(() => state), ...values, processingSetConfig: false }));

View file

@ -5,38 +5,25 @@ import { addErrorToast, addSuccessToast } from './toasts';
import { dashboardState } from './dashboard';
import { redirectToCurrentProtocol } from '../helpers/helpers';
import intl from 'panel/common/intl';
import type { TlsConfig } from 'panel/api/model/tlsConfig';
import type { TlsConfigBody } from 'panel/api/model/tlsConfigBody';
type EncryptionState = {
type EncryptionState = Partial<
Omit<TlsConfig, 'port_https' | 'port_dns_over_tls' | 'port_dns_over_quic' | 'dns_names'>
> & {
processing: boolean;
processingConfig: boolean;
processingValidate: boolean;
enabled: boolean;
serve_plain_dns: boolean;
dns_names: any;
force_https: boolean;
issuer: string;
key_type: string;
not_after: string;
not_before: string;
port_dns_over_tls: any;
port_dns_over_quic: any;
port_https: any;
port_dnscrypt: any;
subject: string;
valid_chain: boolean;
valid_key: boolean;
valid_cert: boolean;
valid_pair: boolean;
status_cert: string;
status_key: string;
certificate_chain: string;
private_key: string;
server_name: string;
warning_validation: string;
certificate_path: string;
private_key_path: string;
private_key_saved: boolean;
allow_unencrypted_doh: boolean;
// All four port fields: number from API, string from form input (initialized as ''):
port_https: number | string;
port_dns_over_tls: number | string;
port_dns_over_quic: number | string;
port_dnscrypt: number | string;
// Store initializes as null, API returns string[]:
dns_names: string[] | null;
};
const initialState: EncryptionState = {
@ -48,7 +35,7 @@ const initialState: EncryptionState = {
dns_names: null,
force_https: false,
issuer: '',
key_type: '',
key_type: 'RSA',
not_after: '',
not_before: '',
port_dns_over_tls: '',
@ -74,13 +61,14 @@ const initialState: EncryptionState = {
const [state, setState] = createStore<EncryptionState>(initialState);
const decodeResponse = (data: any) => {
const fields = ['certificate_chain', 'private_key'];
const decoded = { ...data };
const decodeResponse = (data: TlsConfig): TlsConfig => {
const decoded: TlsConfig = { ...data };
const fields = ['certificate_chain', 'private_key'] as const;
fields.forEach((field) => {
if (decoded[field]) {
const value = decoded[field];
if (typeof value === 'string') {
try {
decoded[field] = atob(decoded[field]);
decoded[field] = atob(value);
} catch {
// keep as is
}
@ -89,12 +77,12 @@ const decodeResponse = (data: any) => {
return decoded;
};
const encodeRequest = (values: any) => {
const encoded = { ...values };
if (encoded.certificate_chain) {
const encodeRequest = (values: TlsConfig): TlsConfig => {
const encoded: TlsConfig = { ...values };
if (typeof encoded.certificate_chain === 'string') {
encoded.certificate_chain = btoa(encoded.certificate_chain);
}
if (encoded.private_key) {
if (typeof encoded.private_key === 'string') {
encoded.private_key = btoa(encoded.private_key);
}
return encoded;
@ -112,25 +100,27 @@ export const getTlsStatus = async () => {
}
};
export const setTlsConfig = async (values: any, opts?: { silent?: boolean }) => {
export const setTlsConfig = async (values: TlsConfigBody, opts?: { silent?: boolean }) => {
setState('processingConfig', true);
try {
// Merge: start with all store values, then override with caller's
// defined values (empty strings / false are intentional overrides).
const fullValues = {
const fullValues: TlsConfig = {
enabled: state.enabled,
serve_plain_dns: state.serve_plain_dns,
server_name: state.server_name,
force_https: state.force_https,
port_https: state.port_https || 0,
port_dns_over_tls: state.port_dns_over_tls || 0,
port_dns_over_quic: state.port_dns_over_quic || 0,
port_https: Number(state.port_https) || 0,
port_dns_over_tls: Number(state.port_dns_over_tls) || 0,
port_dns_over_quic: Number(state.port_dns_over_quic) || 0,
certificate_chain: state.certificate_chain,
private_key: state.private_key,
certificate_path: state.certificate_path,
private_key_path: state.private_key_path,
private_key_saved: state.private_key_saved,
...Object.fromEntries(Object.entries(values).filter(([, v]) => v !== undefined)),
...Object.fromEntries(
Object.entries(values).filter(([, v]) => v !== undefined),
),
};
const encoded = encodeRequest(fullValues);
@ -153,7 +143,7 @@ export const setTlsConfig = async (values: any, opts?: { silent?: boolean }) =>
}
};
export const validateTlsConfig = async (values: any) => {
export const validateTlsConfig = async (values: TlsConfigBody) => {
setState('processingValidate', true);
try {
const encoded = encodeRequest(values);
@ -180,7 +170,7 @@ export const resetValidationStatus = () => {
valid_pair: false,
subject: '',
issuer: '',
key_type: '',
key_type: undefined,
not_after: '',
not_before: '',
dns_names: null,

View file

@ -14,6 +14,10 @@ import { addErrorToast, addSuccessToast, createUndoToast } from './toasts';
import type { Filter } from 'panel/helpers/helpers';
import { normalizeFilteringStatus, normalizeRulesTextarea } from 'panel/helpers/helpers';
import intl from 'panel/common/intl';
import type { FilterCheckHostResponse } from 'panel/api/model/filterCheckHostResponse';
import type { FilterSetUrlData } from 'panel/api/model/filterSetUrlData';
import type { FilterRefreshRequest } from 'panel/api/model/filterRefreshRequest';
import type { FilterConfig } from 'panel/api/model/filterConfig';
type FilteringState = {
isModalOpen: boolean;
@ -29,13 +33,13 @@ type FilteringState = {
isFilterRemoved: boolean;
isFilterEdited: boolean;
filters: Filter[];
whitelistFilters: any[];
whitelistFilters: Filter[];
userRules: string;
interval: number;
enabled: boolean;
modalType: string;
modalFilterUrl: string;
check: any;
check: (FilterCheckHostResponse & { hostname?: string }) | Record<string, never>;
};
const initialState: FilteringState = {
@ -304,7 +308,11 @@ export const removeFilter = async (url: string, whitelist: boolean, name?: strin
}
};
export const toggleFilterStatus = async (url: string, data: any, whitelist: boolean) => {
export const toggleFilterStatus = async (
url: string,
data: FilterSetUrlData,
whitelist: boolean,
) => {
setState('processingConfigFilter', true);
try {
await filteringSetURL({ url, data, whitelist });
@ -316,7 +324,7 @@ export const toggleFilterStatus = async (url: string, data: any, whitelist: bool
}
};
export const editFilter = async (url: string, data: any, whitelist: boolean) => {
export const editFilter = async (url: string, data: FilterSetUrlData, whitelist: boolean) => {
setState('processingConfigFilter', true);
try {
await filteringSetURL({ url, data, whitelist });
@ -329,7 +337,7 @@ export const editFilter = async (url: string, data: any, whitelist: boolean) =>
}
};
export const refreshFilters = async (config: any) => {
export const refreshFilters = async (config: FilterRefreshRequest) => {
setState('processingRefreshFilters', true);
try {
const data = await filteringRefresh(config);
@ -347,7 +355,7 @@ export const refreshFilters = async (config: any) => {
}
};
export const setFiltersConfig = async (config: any) => {
export const setFiltersConfig = async (config: FilterConfig) => {
setState('processingSetConfig', true);
try {
await filteringConfig(config);
@ -365,7 +373,10 @@ export const checkHost = async (
try {
const data = await filteringCheckHost(typeof host === 'string' ? { name: host } : host);
const hostname = typeof host === 'string' ? host : host.name;
setState({ check: { hostname, ...data }, processingCheck: false });
setState({
check: { hostname, ...data },
processingCheck: false,
});
return true;
} catch (error) {
addErrorToast({ error });

View file

@ -3,6 +3,10 @@ import { untrack } from 'solid-js';
import { installGetAddresses, installConfigure, installCheckConfig } from 'panel/api/generated';
import { addErrorToast, addSuccessToast } from './toasts';
import intl from 'panel/common/intl';
import type { InstallInterface } from '../initialState';
import type { NetInterface } from 'panel/api/model/netInterface';
import type { InitialConfiguration } from 'panel/api/model/initialConfiguration';
import type { CheckConfigRequest } from 'panel/api/model/checkConfigRequest';
import {
ALL_INTERFACES_IP,
INSTALL_FIRST_STEP,
@ -38,7 +42,7 @@ type InstallState = {
ip: string;
error: string;
};
interfaces: any[];
interfaces: InstallInterface[];
dnsVersion: string;
};
@ -64,10 +68,15 @@ export const getDefaultAddresses = async () => {
const data = await installGetAddresses();
const normalizedInterfaces = Array.isArray(data.interfaces)
? data.interfaces
: Object.entries(data.interfaces || {}).map(([name, iface]: any) => ({
...iface,
name: iface?.name ?? name,
}));
: Object.entries(data.interfaces || {}).map(
([name, iface]: [string, NetInterface]) => ({
flags: iface.flags,
hardware_address: iface.hardware_address,
ip_addresses: [...iface.ipv4_addresses, ...iface.ipv6_addresses],
mtu: 0,
name: iface.name || name,
}),
);
setState({
web: { ...state.web, port: data.web_port },
dns: { ...state.dns, port: data.dns_port },
@ -93,7 +102,9 @@ export const setAuthData = (auth: Partial<InstallState['auth']>) => {
setState('auth', (prev) => ({ ...prev, ...auth }));
};
export const setAllSettings = async (config: any) => {
export const setAllSettings = async (
config: InitialConfiguration & { confirm_password: string },
) => {
setState({ processingSubmit: true, submitted: false });
try {
const { confirm_password, ...rest } = config;
@ -107,13 +118,21 @@ export const setAllSettings = async (config: any) => {
}
};
export const checkConfig = async (values: any) => {
export const checkConfig = async (values: CheckConfigRequest) => {
setState('processingCheck', true);
try {
const data = await installCheckConfig(values);
setState({
web: { ...values.web, ...data.web },
dns: { ...values.dns, ...data.dns },
web: {
ip: values.web?.ip ?? '',
port: values.web?.port ?? 0,
...data.web,
},
dns: {
ip: values.dns?.ip ?? '',
port: values.dns?.port ?? 0,
...data.dns,
},
staticIp: { ...untrack(() => state.staticIp), ...data.static_ip },
processingCheck: false,
});

View file

@ -10,8 +10,10 @@ import {
QUERY_LOGS_PAGE_LIMIT,
QUERY_LOG_INTERVALS_DAYS,
QUERY_LOG_REASON_FILTER,
type QueryLogFilter,
} from 'panel/helpers/constants';
import { normalizeLogs } from 'panel/helpers/helpers';
import { normalizeLogs, type NormalizedQueryLogItem } from 'panel/helpers/helpers';
import type { GetQueryLogConfigResponse } from 'panel/api/model/getQueryLogConfigResponse';
type QueryLogsState = {
processingGetLogs: boolean;
@ -20,10 +22,10 @@ type QueryLogsState = {
processingSetConfig: boolean;
processingAdditionalLogs: boolean;
interval: number;
logs: any[];
logs: NormalizedQueryLogItem[];
enabled: boolean;
oldest: string;
filter: any;
filter: QueryLogFilter;
isFiltered: boolean;
anonymize_client_ip: boolean;
isDetailed: boolean;
@ -82,7 +84,7 @@ const REASON_FILTER_TO_REASONS: Record<string, string[]> = {
[QUERY_LOG_REASON_FILTER.DNS_REWRITES.QUERY]: ['Rewrite', 'RewriteEtcHosts', 'RewriteRule'],
};
const getReasons = (filter?: any): string[] => {
const getReasons = (filter?: QueryLogFilter): string[] => {
const reason = filter?.reason ?? DEFAULT_LOGS_FILTER.reason;
const status = filter?.status ?? DEFAULT_LOGS_FILTER.status;
if (reason !== 'all') {
@ -91,10 +93,11 @@ const getReasons = (filter?: any): string[] => {
return STATUS_TO_REASONS[status] ?? [];
};
const fetchLogsWithParams = async (olderThan: string, filter?: any) => {
const params: Record<string, any> = {
const fetchLogsWithParams = async (olderThan: string, filter?: QueryLogFilter) => {
const params: Record<string, string | string[] | number | undefined> = {
search: filter?.search ?? DEFAULT_LOGS_FILTER.search,
older_than: olderThan,
limit: QUERY_LOGS_PAGE_LIMIT,
};
const reasons = getReasons(filter);
if (reasons.length > 0) {
@ -105,18 +108,21 @@ const fetchLogsWithParams = async (olderThan: string, filter?: any) => {
};
/** Simple stateless filter: count entries matching the status */
const filterLogsByStatus = (logs: any[], status: string): any[] => {
const filterLogsByStatus = (
logs: NormalizedQueryLogItem[],
status: string,
): NormalizedQueryLogItem[] => {
if (!status || status === 'all') return logs;
const reasons = STATUS_TO_REASONS[status];
if (!reasons || reasons.length === 0) return logs;
return logs.filter((log: any) => reasons.includes(log.reason));
return logs.filter((log) => reasons.includes(log.reason ?? ''));
};
const shortPollQueryLogs = async (
data: { logs: any[]; oldest: string },
filter: any,
total?: { logs: any[]; oldest: string },
): Promise<{ logs: any[]; oldest: string }> => {
data: { logs: NormalizedQueryLogItem[]; oldest: string },
filter: QueryLogFilter,
total?: { logs: NormalizedQueryLogItem[]; oldest: string },
): Promise<{ logs: NormalizedQueryLogItem[]; oldest: string }> => {
const totalData = total
? { logs: [...total.logs, ...data.logs], oldest: data.oldest }
: { logs: data.logs, oldest: data.oldest };
@ -214,7 +220,7 @@ export const getLogsConfig = async () => {
}
};
export const setLogsConfig = async (values: any): Promise<boolean> => {
export const setLogsConfig = async (values: GetQueryLogConfigResponse): Promise<boolean> => {
setState('processingSetConfig', true);
try {
await putQueryLogConfig(values);
@ -227,7 +233,7 @@ export const setLogsConfig = async (values: any): Promise<boolean> => {
}
};
export const setFilteredLogs = async (filter?: any): Promise<boolean> => {
export const setFilteredLogs = async (filter?: QueryLogFilter): Promise<boolean> => {
setState({
filter: filter ?? DEFAULT_LOGS_FILTER,
isFiltered: true,
@ -251,7 +257,7 @@ export const setFilteredLogs = async (filter?: any): Promise<boolean> => {
}
};
export const setLogsFilter = (filter: any) => {
export const setLogsFilter = (filter: QueryLogFilter): void => {
setState({ filter });
};

View file

@ -10,12 +10,8 @@ import {
} from 'panel/api/generated';
import { addErrorToast, addSuccessToast } from './toasts';
import intl from 'panel/common/intl';
type RewriteConfig = {
answer: string;
domain: string;
enabled: boolean;
};
import type { RewriteEntry } from 'panel/api/model/rewriteEntry';
import type { RewriteSettings } from 'panel/api/model/rewriteSettings';
type RewritesState = {
processing: boolean;
@ -25,8 +21,8 @@ type RewritesState = {
processingSettings: boolean;
isModalOpen: boolean;
modalType: string;
currentRewrite: RewriteConfig | Record<string, never>;
list: RewriteConfig[];
currentRewrite: RewriteEntry;
list: RewriteEntry[];
enabled: boolean;
};
@ -45,7 +41,7 @@ const initialState: RewritesState = {
const [state, setState] = createStore<RewritesState>(initialState);
export const toggleRewritesModal = (modalType?: string, currentRewrite?: RewriteConfig) => {
export const toggleRewritesModal = (modalType?: string, currentRewrite?: RewriteEntry) => {
if (modalType !== undefined) {
setState({
isModalOpen: !state.isModalOpen,
@ -62,14 +58,14 @@ export const getRewritesList = async () => {
setState('processing', true);
try {
const data = await rewriteList();
setState({ list: (data || []) as RewriteConfig[], processing: false });
setState({ list: data || [], processing: false });
} catch (error) {
addErrorToast({ error });
setState('processing', false);
}
};
export const addRewrite = async (config: RewriteConfig) => {
export const addRewrite = async (config: RewriteEntry) => {
setState('processingAdd', true);
try {
await rewriteAdd(config);
@ -84,7 +80,7 @@ export const addRewrite = async (config: RewriteConfig) => {
};
export const updateRewrite = async (
config: { target: RewriteConfig; update: RewriteConfig },
config: { target: RewriteEntry; update: RewriteEntry },
options: { showToast?: boolean; closeModal?: boolean } = {},
): Promise<boolean> => {
setState('processingUpdate', true);
@ -104,7 +100,7 @@ export const updateRewrite = async (
}
};
export const deleteRewrite = async (config: RewriteConfig): Promise<boolean> => {
export const deleteRewrite = async (config: RewriteEntry): Promise<boolean> => {
setState('processingDelete', true);
try {
await rewriteDelete(config);
@ -130,7 +126,7 @@ export const getRewriteSettings = async () => {
}
};
export const updateRewriteSettings = async (values: any) => {
export const updateRewriteSettings = async (values: RewriteSettings) => {
setState('processingSettings', true);
try {
await rewriteSettingsUpdate(values);

View file

@ -6,14 +6,17 @@ import {
blockedServicesScheduleUpdate,
} from 'panel/api/generated';
import { addErrorToast } from './toasts';
import type { BlockedServicesSchedule } from 'panel/api/model/blockedServicesSchedule';
import type { BlockedService } from 'panel/api/model/blockedService';
import type { ServiceGroup } from 'panel/api/model/serviceGroup';
type ServicesState = {
processing: boolean;
processingAll: boolean;
processingSet: boolean;
list: any;
allServices: any[];
allGroups: any[];
list: BlockedServicesSchedule;
allServices: BlockedService[];
allGroups: ServiceGroup[];
};
const initialState: ServicesState = {
@ -53,7 +56,7 @@ export const getAllBlockedServices = async () => {
}
};
export const updateBlockedServices = async (values: { ids: string[]; schedule?: unknown }) => {
export const updateBlockedServices = async (values: BlockedServicesSchedule) => {
setState('processingSet', true);
try {
await blockedServicesScheduleUpdate(values);

View file

@ -14,6 +14,8 @@ import {
import { addErrorToast, addSuccessToast } from './toasts';
import { splitByNewLine } from 'panel/helpers/helpers';
import intl from 'panel/common/intl';
import type { SafeSearchConfig } from 'panel/api/model/safeSearchConfig';
import type { UpstreamsConfig } from 'panel/api/model/upstreamsConfig';
type SettingsState = {
processing: boolean;
@ -22,7 +24,7 @@ type SettingsState = {
settingsList: {
parental: { enabled: boolean };
safebrowsing: { enabled: boolean };
safesearch: Record<string, boolean>;
safesearch: SafeSearchConfig;
};
};
@ -47,7 +49,7 @@ export const initSettings = async () => {
setState({
settingsList: {
safebrowsing: { enabled: safebrowsingStatusData.enabled },
parental: { enabled: parentalStatusData.enable },
parental: { enabled: parentalStatusData.enabled },
safesearch: { ...safesearchStatusData },
},
processing: false,
@ -58,7 +60,18 @@ export const initSettings = async () => {
}
};
export const toggleSetting = async (settingKey: string, status: any) => {
export async function toggleSetting(
settingKey: 'safesearch',
status: SafeSearchConfig,
): Promise<boolean>;
export async function toggleSetting(
settingKey: 'safebrowsing' | 'parental',
status: boolean,
): Promise<boolean>;
export async function toggleSetting(
settingKey: string,
status: boolean | SafeSearchConfig,
): Promise<boolean> {
try {
switch (settingKey) {
case 'safebrowsing':
@ -78,8 +91,8 @@ export const toggleSetting = async (settingKey: string, status: any) => {
setState('settingsList', 'parental', 'enabled', !status);
return true;
case 'safesearch':
await safesearchSettings(status);
setState('settingsList', 'safesearch', status);
await safesearchSettings(status as SafeSearchConfig);
setState('settingsList', 'safesearch', status as SafeSearchConfig);
return true;
default:
return false;
@ -88,7 +101,7 @@ export const toggleSetting = async (settingKey: string, status: any) => {
addErrorToast({ error });
return false;
}
};
}
export const settingsState = untrack(() => state);
@ -109,7 +122,7 @@ export const testUpstreamWithFormValues = async (
lines.filter((line) => !line.startsWith('#') && !line.startsWith('!'));
const removeComments = (text: string) => filterOutComments(splitByNewLine(text));
const config: any = {
const config: UpstreamsConfig = {
bootstrap_dns: splitByNewLine(bootstrap_dns),
private_upstream: splitByNewLine(local_ptr_upstreams),
fallback_dns: splitByNewLine(fallback_dns),

View file

@ -17,6 +17,8 @@ import {
getParamsForClientsSearch,
secondsToMilliseconds,
} from 'panel/helpers/helpers';
import type { GetStatsConfigResponse } from 'panel/api/model/getStatsConfigResponse';
import type { ClientFindSubEntry } from 'panel/api/model/clientFindSubEntry';
type StatsState = {
processingGetConfig: boolean;
@ -30,7 +32,7 @@ type StatsState = {
replacedParental: number[];
replacedSafebrowsing: number[];
topBlockedDomains: { name: string; count: number }[];
topClients: { name: string; count: number; info: any }[];
topClients: { name: string; count: number; info: ClientFindSubEntry }[];
normalizedTopClients: {
auto: Record<string, number>;
configured: Record<string, number>;
@ -142,7 +144,7 @@ export const getStatsConfig = async () => {
}
};
export const setStatsConfig = async (values: any): Promise<boolean> => {
export const setStatsConfig = async (values: GetStatsConfigResponse): Promise<boolean> => {
setState('processingSetConfig', true);
try {
await putStatsConfig(values);

View file

@ -10,12 +10,14 @@ type ToastAction = {
};
/** Payload accepted by addSuccessToast. */
type SuccessToastPayload = string | {
message: string;
code?: string;
actionLabel?: string;
undoId?: string;
};
type SuccessToastPayload =
| string
| {
message: string;
code?: string;
actionLabel?: string;
undoId?: string;
};
/** Payload accepted by addErrorToast / addWarningToast. */
type ErrorToastPayload = {
@ -24,7 +26,7 @@ type ErrorToastPayload = {
action?: ToastAction;
};
type ToastNotice = {
export type ToastNotice = {
id: string;
message: string;
type: 'error' | 'success' | 'notice' | 'warning';

View file

@ -75,7 +75,7 @@ const config = {
use: 'yaml-loader',
},
{
test: /\.(svg|png|jpe?g|gif|webp|ico)$/i,
test: /\.(svg|png|jpe?g|gif|webp|ico|woff2?)$/i,
type: 'asset/resource',
generator: {
filename: 'assets/[name].[contenthash][ext]',

View file

@ -865,7 +865,7 @@
'schema':
'type': 'object'
'properties':
'enable':
'enabled':
'type': 'boolean'
'sensitivity':
'type': 'integer'
@ -1508,6 +1508,9 @@
- 'dns_port'
- 'http_port'
- 'protection_enabled'
# TODO: 'protection_disabled_until' in required does not match the property name
# 'protection_disabled_duration' below. Check which name the
# backend actually uses and align them.
- 'protection_disabled_until'
- 'running'
- 'version'
@ -1592,13 +1595,13 @@
'type': 'boolean'
'ratelimit':
'type': 'integer'
'ratelimit_subnet_subnet_len_ipv4':
'ratelimit_subnet_len_ipv4':
'description': 'Length of the subnet mask for IPv4 addresses.'
'type': 'integer'
'default': 24
'minimum': 0
'maximum': 32
'ratelimit_subnet_subnet_len_ipv6':
'ratelimit_subnet_len_ipv6':
'description': 'Length of the subnet mask for IPv6 addresses.'
'type': 'integer'
'default': 56
@ -2118,6 +2121,8 @@
'DhcpStatus':
'type': 'object'
'description': 'Built-in DHCP server configuration and status'
# TODO: 'config' in required does not exist as a property. The backend returns a flat object
# (no 'config' wrapper). Remove 'config' from required.
'required':
- 'config'
- 'leases'
@ -2594,6 +2599,14 @@
'example': true
'description': >
Set to true if plain DNS is allowed for incoming requests.
'port_dnscrypt':
'type': 'integer'
'format': 'uint16'
'example': 5443
'description': 'DNS-over-HTTPS port. If 0, DNSCrypt will be disabled.'
'dnscrypt_config_file':
'type': 'string'
'description': 'Path to the DNSCrypt configuration file.'
'NetInterface':
'type': 'object'
'description': 'Network interface info'