Merge branch 'master' into AGDNS-3863-gopacket-dhcp-vol.31
Some checks failed
build / test (macOS-latest) (push) Has been cancelled
build / test (ubuntu-latest) (push) Has been cancelled
build / test (windows-latest) (push) Has been cancelled
lint / go-lint (push) Has been cancelled
lint / eslint (push) Has been cancelled
build / build-release (push) Has been cancelled
build / notify (push) Has been cancelled
lint / notify (push) Has been cancelled

This commit is contained in:
Eugene Burkov 2026-07-27 17:36:42 +03:00
commit 8ae926b76c
270 changed files with 7867 additions and 2689 deletions

1
.prettierignore Normal file
View file

@ -0,0 +1 @@
.twosky.json

View file

@ -32,6 +32,13 @@ NOTE: Add new changes BELOW THIS COMMENT.
### Fixed
- Multiple inaccuracies in the OpenAPI specification:
- Wrong property names: `enable``enabled` in the Parental status response, `protection_disabled_until``protection_disabled_duration` in `ServerStatus`, `ratelimit_subnet_subnet_len_ipv4` and `ratelimit_subnet_subnet_len_ipv6` in `DNSConfig`.
- `upstream_mode` enum in `DNSConfig` changed from object to string format for compatibility with code generators.
- Missing required properties in `DhcpConfigV4` and `DhcpStatus` schemas.
- Missing `port_dnscrypt` and `dnscrypt_config_file` properties in `TlsConfig`.
- Split `NetInterface` into `NetInterface` and `DHCPNetInterface` schemas; `GET /dhcp/interfaces` now uses `DHCPNetInterfaces`.
- Blocked requests without an EDNS(0) OPT record ([#8183]).
[#8183]: https://github.com/AdguardTeam/AdGuardHome/issues/8183

View file

@ -31,6 +31,7 @@ module.exports = {
'@typescript-eslint/no-unused-vars': [
'error',
{
varsIgnorePattern: '^_',
argsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
@ -67,4 +68,13 @@ module.exports = {
},
],
},
overrides: [
{
// Orval-generated files — max-len is controlled by prettier
files: ['src/api/generated.ts', 'src/api/model/**/*.ts'],
rules: {
'max-len': 'off',
},
},
],
};

View file

@ -1 +1,2 @@
../.twosky.json
src/common/intl/locales.generated.ts

View file

@ -2,24 +2,24 @@
## Table of Contents
- [AGENTS.md — AdGuard Home client\_v2 (SolidJS Frontend)](#agentsmd--adguard-home-client_v2-solidjs-frontend)
- [Table of Contents](#table-of-contents)
- [AGENTS.md — AdGuard Home client_v2 (SolidJS Frontend)](#agentsmd--adguard-home-client_v2-solidjs-frontend)
- [Table of Contents](#table-of-contents)
- [Project Overview](#project-overview)
- [Technical Context](#technical-context)
- [Project Structure](#project-structure)
- [Build And Test Commands](#build-and-test-commands)
- [Contribution Instructions](#contribution-instructions)
- [Code Guidelines](#code-guidelines)
- [System Design](#system-design)
- [Architecture](#architecture)
- [Code Quality](#code-quality)
- [Testing](#testing)
- [Dependency Management](#dependency-management)
- [Configuration \& Documentation](#configuration--documentation)
- [Markdown Formatting](#markdown-formatting)
- [Other](#other)
- [Accessibility](#accessibility)
- [Translations](#translations)
- [System Design](#system-design)
- [Architecture](#architecture)
- [Code Quality](#code-quality)
- [Testing](#testing)
- [Dependency Management](#dependency-management)
- [Configuration \& Documentation](#configuration--documentation)
- [Markdown Formatting](#markdown-formatting)
- [Other](#other)
- [Accessibility](#accessibility)
- [Translations](#translations)
# Project Overview
@ -54,7 +54,8 @@ Playwright (e2e).
- **State**: SolidJS `createStore` module-scoped stores (no Redux, no Context
providers)
- **HTTP**: Native `fetch` wrapped in a single `Api` class
- **i18n**: `@adguard/translate` with 13 locale JSON files in `src/__locales/`
- **i18n**: `@adguard/translate` with 35 bundled locales from `src/__locales/`
(generated from `.twosky.json` via `npm run locales:generate`)
- **Testing**: Vitest 4 + `@solidjs/testing-library` (unit);
Playwright 1.56 (e2e against a real backend)
- **Linting/Formatting**: ESLint 8 (`@typescript-eslint`, `eslint-plugin-solid`,
@ -77,7 +78,7 @@ client_v2/
│ ├── index.tsx # Main app entry (renders <App/>)
│ ├── index.pcss # Global CSS (vars, reset)
│ ├── initialState.ts # Domain model types + initial form state
│ ├── api/ # Single Api class — all HTTP calls
│ ├── api/ # Orval-generated typed client + customFetch mutator
│ ├── common/
│ │ ├── controls/ # Input primitives (Checkbox, Input, Select, Switch, …)
│ │ ├── ui/ # Higher-level UI (Button, Dialog, Table, Tabs, Sidebar, …)
@ -127,6 +128,8 @@ All commands are run from the `client_v2/` directory.
| Unit tests (watch) | `npm run test:watch` |
| E2e tests | `npm run test:e2e` |
| E2e interactive UI | `npm run test:e2e:interactive` |
| Locale codegen | `npm run locales:generate` |
| Locale freshness check | `npm run locales:check` |
| Translation check | `npm run translations:check` |
| Full check (lint + typecheck + test) | `npm run check` |
@ -166,9 +169,10 @@ no server process of its own. Design for the browser environment:
- **Stateless client, stateful backend.** The UI holds no durable state; all
configuration and data persist in the AdGuard Home backend via the `/control`
HTTP API. Treat the browser as a thin view layer.
- **Single source of HTTP.** All backend calls go through the singleton
`apiClient` in `src/api/Api.ts`. Do not call `fetch` directly from components
or stores — add a typed method to the `Api` class instead.
- **Single source of HTTP.** All backend calls go through the orval-generated
typed client in `src/api/generated.ts` (with the `customFetch` mutator in
`src/api/customFetch.ts`). Do not call `fetch` directly from components
or stores — import a generated function instead.
- **No shared server memory.** Multiple browser tabs / reloads are independent;
never rely on in-memory module state surviving a reload. Re-fetch data in
component `onMount` rather than assuming a store is already populated.
@ -199,8 +203,9 @@ Universal design principles the codebase follows:
→ api → backend. Stores never import components; api never imports stores.
- **Explicit Boundaries** — modules interact through named exports; no
reaching into another module's internals.
- **Data Flow Clarity** — user action → store action function → `apiClient`
`setState` → reactive UI update. Data moves in one predictable path.
- **Data Flow Clarity** — user action → store action function → generated
API function → `setState` → reactive UI update. Data moves in one
predictable path.
- **Minimize Coupling, Maximize Cohesion** — stores are self-contained and
imported directly (no Context providers); components depend on narrow store
exports.
@ -221,7 +226,7 @@ This project's layers, from top to bottom:
| Components | Render UI, handle user interaction | `src/components/Dashboard/`, `src/components/Clients/` |
| Common UI | Reusable controls and primitives | `src/common/controls/Select/`, `src/common/ui/Button/` |
| Stores | Domain state + async actions | `src/stores/clients.ts`, `src/stores/queryLogs.ts` |
| API | HTTP transport to backend | `src/api/Api.ts` |
| API | HTTP transport to backend | `src/api/generated.ts`, `src/api/customFetch.ts` |
| Helpers | Pure utilities, validators | `src/helpers/`, `src/lib/` |
```text
@ -229,7 +234,7 @@ Components (pages, controls)
Stores (domain state + actions)
API (apiClient → fetch /control)
API (generated functions → customFetch → fetch /control)
AdGuard Home backend (Go)
```
@ -246,7 +251,7 @@ must not depend on stores or components. Helpers are pure and dependency-free.
## Code Quality
- **Path aliases**: Use the `panel/*` alias for all `src/` imports
(e.g. `import { apiClient } from 'panel/api/Api'`). Use `Twosky` for the
(e.g. `import { status } from 'panel/api/generated'`). Use `Twosky` for the
root `.twosky.json` config. Avoid deep relative paths (`../../..`).
- **Component conventions**: PascalCase directories and component files.
Each component lives in its own directory with an `index.tsx` and a
@ -284,12 +289,12 @@ must not depend on stores or components. Helpers are pure and dependency-free.
`createMemo` (e.g., table columns), **access the prop in the memo body**
so SolidJS tracks the dependency.
- **Stores**: Module-scoped `createStore` singletons exported directly — no
Context/Provider. Async actions set a `processing*` flag, call `apiClient`,
then `setState`. Errors are reported via `addErrorToast`.
Context/Provider. Async actions set a `processing*` flag, call generated
API functions, then `setState`. Errors are reported via `addErrorToast`.
- **Naming**: Files `PascalCase.tsx` for components, `camelCase.ts` for
stores/helpers. CSS module files `*.module.pcss`.
- **Error handling**: API errors throw from `Api.ts`; store actions catch and
surface a toast. Do not swallow errors silently.
- **Error handling**: API errors throw from `customFetch.ts`; store actions
catch and surface a toast. Do not swallow errors silently.
- **Logging**: `console.warn` and `console.error` are allowed; `console.log`
is disallowed by ESLint.
- **Static analysis gates**: ESLint, Prettier, and `tsc --noEmit` must pass.
@ -312,8 +317,8 @@ must not depend on stores or components. Helpers are pure and dependency-free.
`@testing-library/jest-dom` matchers.
- **Store/helper tests**: Call exported action/builder functions directly and
assert on returned values or state (e.g.
`src/__tests__/clientForm/buildClientConfig.test.ts`). Mock `apiClient` when
a test would otherwise hit the network.
`src/__tests__/clientForm/buildClientConfig.test.ts`). Mock generated API
functions when a test would otherwise hit the network.
- **Naming**: Mirror the source path under `__tests__/`
(e.g. `stores/clients.ts``__tests__/stores/clients.test.ts`).
- **E2E tests**: Playwright, configured in `playwright.config.ts`
@ -439,6 +444,12 @@ friendly:
- All user-facing strings must be localized. Add new keys to the base locale
`src/__locales/en.json`; other locales are managed externally via Twosky.
- Locale imports are **generated** — do not hand-edit.
When `.twosky.json` or the set of locale JSON files changes, run:
```sh
npm run locales:generate
```
CI verifies freshness with `npm run locales:check`.
- Access translations via the `intl` object from `panel/common/intl`:
- `intl.getMessage('key', values?)` — returns a plain localized string.
Pass interpolation values as the second argument, referenced inside the

View file

@ -214,6 +214,16 @@ This audits source files for `intl.getMessage` / `intl.getPlural` usage and
reports missing, unused, and dynamic keys. Other locales are managed
externally via Twosky (configured in the root `.twosky.json`).
When `.twosky.json` changes (a language is added or removed), regenerate the
locale import file:
```sh
npm run locales:generate
```
This writes `src/common/intl/locales.generated.ts`, which is committed.
CI verifies freshness with `npm run locales:check`.
## Common Tasks
### Adding a Component

27
client_v2/orval.config.ts Normal file
View file

@ -0,0 +1,27 @@
import { defineConfig } from 'orval';
export default defineConfig({
adguardHome: {
input: {
target: '../openapi/openapi.yaml',
},
output: {
target: './src/api/generated.ts',
schemas: './src/api/model',
client: 'fetch',
baseUrl: 'control',
prettier: true,
override: {
header: false,
mutator: {
path: './src/api/customFetch.ts',
name: 'customFetch',
},
fetch: {
includeHttpResponseReturnType: false,
},
enumGenerationType: 'union',
},
},
},
});

File diff suppressed because it is too large Load diff

View file

@ -19,7 +19,10 @@
"test:e2e:codegen": "npx playwright codegen",
"typecheck": "tsc --noEmit",
"typecheck:watch": "tsc --noEmit --watch",
"translations:check": "node ./scripts/check-translations.js"
"translations:check": "node ./scripts/check-translations.js",
"locales:generate": "node ./scripts/generate-locales.js",
"locales:check": "node ./scripts/check-locales.js",
"api:generate": "orval --config ./orval.config.ts && bash scripts/postgenerate.sh"
},
"type": "module",
"dependencies": {
@ -34,7 +37,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",
@ -48,7 +50,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",
@ -69,6 +70,7 @@
"html-webpack-plugin": "^5.6.0",
"jsdom": "^26.1.0",
"mini-css-extract-plugin": "^2.9.0",
"orval": "^8.22.0",
"playwright": "^1.61.0",
"postcss": "^8.5.6",
"postcss-import": "^16.1.1",

View file

@ -0,0 +1,54 @@
/**
* CI freshness guard for `src/common/intl/locales.generated.ts`.
*
* Generates the file to a temp location and compares it against the
* committed version. No working-tree side effects the real file
* is never modified by this script.
*
* Usage:
* node ./scripts/check-locales.js
* npm run locales:check
*/
import { execFileSync } from 'node:child_process';
import { readFileSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const GEN_SCRIPT = resolve(__dirname, 'generate-locales.js');
const REAL_FILE = resolve(__dirname, '..', 'src', 'common', 'intl', 'locales.generated.ts');
const TMP_FILE = resolve(tmpdir(), 'locales.generated.ts');
let exitCode = 0;
try {
// Generate to a temp file so we never touch the real working tree.
execFileSync(process.execPath, [GEN_SCRIPT, '--out', TMP_FILE], { stdio: 'inherit' });
// Compare contents directly — the file is small enough that a full
// string comparison is both fast and precise.
const generated = readFileSync(TMP_FILE, 'utf-8');
const committed = readFileSync(REAL_FILE, 'utf-8');
if (generated !== committed) {
console.error('');
console.error('ERROR: locales.generated.ts is out of date.');
console.error(' Run: npm run locales:generate');
console.error('');
exitCode = 1;
} else {
console.log('locales.generated.ts is up to date.');
}
} finally {
// Always clean up the temp file, even when the check fails.
try {
unlinkSync(TMP_FILE);
} catch {
// Best effort — the file might not exist if generate-locales itself
// failed early.
}
}
process.exit(exitCode);

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

@ -0,0 +1,129 @@
/**
* Generates `src/common/intl/locales.generated.ts` from the Twosky language
* list in `.twosky.json` (project: `home_v2`).
*
* Usage:
* node ./scripts/generate-locales.js # regenerate the file
* npm run locales:generate # same, via package.json
*
* CI freshness guard:
* npm run locales:check # fails if output is stale
*
* The generated file contains static import statements and a LOCALES map so
* that webpack can tree-shake each locale JSON bundle. Every locale from
* `.twosky.json` `home_v2.languages` is included except those listed in
* EXCLUDES below.
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
// --- Configuration ---
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, '..', '..');
const TWOSKY_PATH = resolve(REPO_ROOT, '.twosky.json');
// Allow the caller to override the output path via `--out <path>`.
// Useful for CI checks that want to generate to a temp file.
const args = process.argv.slice(2);
const outFlagIdx = args.indexOf('--out');
const OUTPUT_PATH =
outFlagIdx !== -1 && args[outFlagIdx + 1]
? resolve(args[outFlagIdx + 1])
: resolve(__dirname, '..', 'src', 'common', 'intl', 'locales.generated.ts');
// --- Helpers ---
/** Turn a hyphenated locale code into a valid JavaScript identifier.
* E.g. "pt-br" "ptBr", "zh-hk" "zhHk", "sr-cs" "srCs".
*/
const toAlias = (code) => code.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
// --- Main ---
try {
const twoskyRaw = readFileSync(TWOSKY_PATH, 'utf-8');
const twosky = JSON.parse(twoskyRaw);
const homeV2 = twosky.find((p) => p.project_id === 'home_v2');
if (!homeV2) {
console.error('ERROR: no project with project_id "home_v2" found in .twosky.json');
process.exit(1);
}
const allCodes = Object.keys(homeV2.languages);
// Stable sort for deterministic output
allCodes.sort();
// Verify every locale JSON file exists before writing output
const localesDir = resolve(__dirname, '..', 'src', '__locales');
for (const code of allCodes) {
const jsonPath = resolve(localesDir, `${code}.json`);
try {
readFileSync(jsonPath);
} catch {
console.error(
`ERROR: locale JSON file not found: ${jsonPath}` +
`\n Ensure the file was downloaded from Twosky before regenerating.`,
);
process.exit(1);
}
}
// --- Emit ---
const baseLocale = homeV2.base_locale || 'en';
const lazyCodes = allCodes.filter((code) => code !== baseLocale);
const lines = [
'/* eslint-disable */',
'// AUTO-GENERATED by `npm run locales:generate`. Do not edit by hand.',
'// Source: .twosky.json → project_id: home_v2 → languages',
'// Excludes: (none)',
'',
'export type LocaleMessage = Record<string, string>;',
'',
];
// Static import for the base locale (always bundled, synchronous fallback)
const baseAlias = toAlias(baseLocale);
lines.push(`import ${baseAlias} from 'panel/__locales/${baseLocale}.json';`);
lines.push('');
// Lazy-loaders for every non-base locale.
// Return type is Promise<any> because JSON dynamic imports produce
// { default: LocaleMessage }; the preloadLocale() helper unwraps
// the .default at runtime.
lines.push('export const LOCALE_LOADERS: Record<string, () => Promise<any>> = {');
for (let i = 0; i < lazyCodes.length; i++) {
const code = lazyCodes[i];
const comma = i === lazyCodes.length - 1 ? '' : ',';
lines.push(
` '${code}': () => import(/* webpackChunkName: "locale.${code}" */ 'panel/__locales/${code}.json')${comma}`,
);
}
lines.push('};');
lines.push('');
// All known locale codes (used by resolveLanguage to validate codes
// before messages are loaded)
const codeList = allCodes.map((c) => `'${c}'`).join(', ');
lines.push(`export const LOCALE_CODES = new Set([${codeList}]);`);
lines.push('');
// Synchronously available locale data — starts with only the base
// locale, expanded by preloadLocale() at runtime.
lines.push('export const LOCALES: Record<string, LocaleMessage> = {');
lines.push(` ${baseLocale}: ${baseAlias},`);
lines.push('};');
lines.push('');
writeFileSync(OUTPUT_PATH, lines.join('\n') + '\n', 'utf-8');
console.log(`Wrote ${OUTPUT_PATH} (${allCodes.length} locales)`);
} catch (err) {
console.error('Failed to generate locales.generated.ts:', err.message);
process.exit(1);
}

View file

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
npx prettier --write "src/api/generated.ts" "src/api/model/**/*.ts"
npx eslint --quiet --fix "src/api/generated.ts" "src/api/model/**/*.ts" || true

View file

@ -1,11 +1,63 @@
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 → Amharic: identical CLDR plural rules (one: n=0..1, other)
// This only affects plural-form indexing; actual strings come from si-lk.json
//
// TODO(ik): Contribute missing `si` locale to @adguard/translate
if (code === 'si-lk') {
return 'am';
}
// 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 +268,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

@ -209,6 +209,7 @@
"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 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",
@ -448,7 +449,6 @@
"install_auth_password_enter": "Enter password",
"install_auth_username": "Username",
"install_auth_username_enter": "Enter username",
"install_saved": "Saved successfully",
"install_settings_all_interfaces": "All interfaces",
"install_settings_port": "Port",
"install_static_configure": "AdGuard Home has detected that the dynamic IP address %ip% is used. Do you want it to be set as your static address?",
@ -503,7 +503,7 @@
"parental_group_software": "Software development",
"parental_group_streaming": "Streaming",
"password_label": "Password",
"password_login_error": "Your username and password do not match",
"password_login_error": "Unrecognized password",
"password_placeholder": "Enter password",
"password_requirements": "Password requirements",
"password_requirements_characters": "Minimum 8 characters",
@ -615,6 +615,7 @@
"save": "Save",
"save_btn": "Save",
"search_placeholder": "Search",
"select_language": "Select language",
"server_config_blocking_mode_ipv4_faq": "IP address to return for a blocked A request",
"server_config_blocking_mode_ipv6_faq": "IP address to return for a blocked AAAA request",
"server_config_blocking_mode_ttl_faq": "Specifies how long clients should cache a filtered response",
@ -882,5 +883,6 @@
"yes_disable": "Yes, disable",
"yes_disallow": "Yes, disallow",
"yes_remove": "Yes, remove",
"yes_reset": "Yes, reset"
"yes_reset": "Yes, reset",
"logs": "Logs"
}

View file

@ -17,6 +17,7 @@
"add_blocklist": "添加黑名单",
"add_persistent_client": "添加为持久客户端",
"add_tls_certificate": "添加 TLS 证书",
"add_tls_certificate_private_key": "添加 TLS 证书私钥",
"add_to_allowlist": "添加到白名单",
"ads_blocked": "广告已拦截",
"ads_blocked_card": "广告已拦截",
@ -36,9 +37,14 @@
"allowlist_remove": "移除允许列表?",
"allowlist_remove_desc": "将移除 <strong>%value%</strong>",
"allowlists": "允许列表",
"allowlists_desc": "允许列表中的域名即使同时存在于拦截列表中也不会被拦截",
"allowlists_title": "允许列表",
"apply": "应用",
"aria_clear_input": "清除输入",
"aria_next_page": "下一页",
"aria_previous_page": "上一页",
"auto_clients_desc": "关于可以使用 AdGuard Home 的设备的 IP 地址信息",
"auto_clients_title": "运行时",
"autofix_warning_list": "<p>它将执行以下任务:</p><p>停用系统 DNSStubListener</p><p>将 DNS 服务器地址设为 127.0.0.1</p><p>将 /etc/resolv.conf 的符号链接目标替换为 /run/systemd/resolve/resolv.conf</p><p>停止 DNSStubListener重新加载 systemd-resolved 服务)</p>",
"autofix_warning_result": "因此,默认情况下所有来自系统的 DNS 请求都将由 AdGuard Home 处理。",
"autofix_warning_text": "若您单击「修复」AdGuard Home 将会配置您的系统以使用 AdGuard Home 的 DNS 服务器。",
@ -113,6 +119,7 @@
"client_identifier": "标识符",
"client_ip": "客户端 IP",
"client_location": "客户端位置",
"client_name_already_exists": "此客户端名称已存在",
"client_removed": "客户端已移除",
"client_settings": "客户端设置",
"client_table_header": "客户端",
@ -140,6 +147,8 @@
"clients_remove_title": "删除客户端?",
"clients_tags": "标签",
"clients_tags_desc": "您可以为此客户端添加标签,并将其包含在过滤规则中。<a>了解更多</a>",
"clients_tags_placeholder": "添加标签",
"clients_title": "持久客户端",
"clients_upstreams_desc": "上游 DNS 服务器,每行一个。",
"clients_use_dns_cache": "使用 DNS 缓存",
"clients_use_global_settings": "使用全局设置",
@ -165,21 +174,30 @@
"delete_table_action": "删除",
"delete_table_action_confirm": "确认删除",
"device_type": "设备类型",
"dhcp": "DHCP",
"dhcp_add_static_lease": "添加静态租约",
"dhcp_config_saved": "已成功保存 DHCP 服务器配置",
"dhcp_dynamic_ip_found": "您的系统对接口 %interface_name% 使用了动态 IP 地址配置。要使用 DHCP 服务器,必须设置静态 IP 地址。您当前的 IP 地址是 %ip%。如果您按下「启用 DHCP 服务器」按钮AdGuard Home 将自动将此 IP 设为静态地址。",
"dhcp_edit_static_lease": "编辑静态租约",
"dhcp_enable": "使用 AdGuard 内置的 DHCP 服务器",
"dhcp_error": "检查 DHCP 服务器失败。请稍后重试。",
"dhcp_form_gateway_address": "网关 IP 地址",
"dhcp_form_gateway_address_value": "<strong>网关 IP 地址:</strong>%value%",
"dhcp_form_gateway_input": "网关 IP",
"dhcp_form_lease_title": "DHCP 租约时间,单位:秒",
"dhcp_form_range_start": "起始 IP 地址",
"dhcp_form_range_title": "IP 地址范围",
"dhcp_form_subnet_input": "子网掩码",
"dhcp_found": "无法启用 DHCP 服务器",
"dhcp_hardware_address_value": "<strong>硬件地址:</strong>%value%",
"dhcp_interface_select": "DHCP 接口",
"dhcp_ip_addresses_value": "<strong>IP 地址:</strong>%value%",
"dhcp_ipv4_settings": "DHCP IPv4 设置",
"dhcp_ipv6_settings": "DHCP IPv6 设置",
"dhcp_lease_added": "静态租约「%key%」已成功添加",
"dhcp_lease_deleted": "静态租约「%key%」已成功删除",
"dhcp_lease_updated": "静态租约「%key%」已成功更新",
"dhcp_leases": "动态租约",
"dhcp_leases_not_found": "未找到 DHCP 租约",
"dhcp_leases_title": "DHCP 租约",
"dhcp_mac_address_already_added": "此 MAC 地址已添加",
@ -191,66 +209,117 @@
"dhcp_reset_leases_success": "成功重置了 DHCP 租约",
"dhcp_settings": "DHCP 设置",
"dhcp_static_ip_error": "DHCP 服务器需要静态 IP 地址。我们无法确认此网络接口已配置静态 IP。",
"dhcp_static_leases": "静态租约",
"dhcp_static_leases_not_found": "未找到 DHCP 静态租约",
"dhcp_table_hostname": "主机名",
"dhcp_table_ip_address": "IP 地址",
"dhcp_table_mac_address": "MAC 地址",
"dhcp_v6_unavailable": "不可用",
"dhcp_warning": "要启用此 DHCP 服务器,请停用网络上所有其他 DHCP 服务器。否则,设备可能会失去互联网连接。",
"disable": "禁用",
"disable_dns_rewrites": "禁用 DNS 重写?",
"disable_protection_btn": "禁用保护",
"disabled_dhcp": "DHCP 服务器已禁用",
"disallow_client_confirm_allowlist_note": "此操作还将从允许的客户端列表中移除规则 %ip%。",
"disallow_client_confirm_text": "这将丢弃来自客户端 <strong>%ip%</strong> 的所有后续 DNS 请求,并将其从允许的客户端列表中移除。",
"disallow_client_confirm_title": "禁止此客户端?",
"disallow_this_client": "不允许这个客户端",
"dns_access_settings_title": "访问设置",
"dns_allowed_clients": "允许的客户端",
"dns_allowed_clients_desc": "仅接受此列表中客户端的请求",
"dns_allowed_clients_desc_2": "要添加客户端,请输入其 <a>CIDR、IP 地址或 ClientID</a>",
"dns_allowed_clients_label": "允许的客户端,每行一个",
"dns_allowed_clients_placeholder": "输入允许的客户端",
"dns_blocking_mode": "拦截模式",
"dns_blocking_mode_custom_ip": "自定义 IP 地址",
"dns_blocking_mode_custom_ip_desc": "以手动设置的 IP 地址响应",
"dns_blocking_mode_default": "默认",
"dns_blocking_mode_default_desc": "如果被 Adblock 风格规则拦截,对 A 记录响应 0.0.0.0,对 AAAA 记录响应 ::。如果被 hosts 风格规则拦截,则响应规则中指定的 IP 地址。",
"dns_blocking_mode_desc": "设置对被拦截请求的响应类型",
"dns_blocking_mode_ipv4_label": "拦截 IPv4",
"dns_blocking_mode_ipv4_placeholder": "输入 IPv4 地址",
"dns_blocking_mode_ipv6_label": "拦截 IPv6",
"dns_blocking_mode_ipv6_placeholder": "输入 IPv6 地址",
"dns_blocking_mode_null_ip": "空 IP 地址",
"dns_blocking_mode_null_ip_desc": "对 A 记录响应 0.0.0.0,对 AAAA 记录响应 ::",
"dns_blocking_mode_nxdomain": "NXDOMAIN",
"dns_blocking_mode_nxdomain_desc": "以 NXDOMAIN 状态码响应",
"dns_blocking_mode_refused": "REFUSED",
"dns_blocking_mode_refused_desc": "以 REFUSED 状态码响应",
"dns_blocking_mode_title": "拦截模式",
"dns_blocking_mode_ttl_label": "拦截响应 TTL",
"dns_blocking_mode_ttl_placeholder": "输入 TTL",
"dns_bootstrap_dns_desc": "设置用于解析 DoH/DoT 上游解析器主机名的 DNS 服务器",
"dns_bootstrap_dns_desc_2": "每行输入一个 IP 地址。注释必须以 # 开头且另起一行。",
"dns_bootstrap_dns_label": "Bootstrap DNS 服务器,每行一个",
"dns_bootstrap_dns_placeholder": "IP 地址",
"dns_bootstrap_dns_title": "Bootstrap DNS 服务器",
"dns_bootstrap_servers": "Bootstrap DNS 服务器",
"dns_cache_config": "DNS 缓存配置",
"dns_cache_configuration_saved_toast": "DNS 缓存配置已保存",
"dns_cache_desc": "在本地存储 DNS 响应",
"dns_cache_size": "缓存大小",
"dns_cache_size_desc": "设置 DNS 缓存大小",
"dns_cache_size_label": "DNS 缓存大小,单位:字节",
"dns_cache_size_title": "缓存大小",
"dns_cache_title": "DNS 缓存",
"dns_clear_cache": "清除缓存",
"dns_clear_cache_cancel": "取消",
"dns_clear_cache_confirm": "是的,清除",
"dns_clear_cache_desc": "这将清除您的设备 DNS 缓存,并使其从 DNS 服务器请求新的信息",
"dns_clear_cache_title": "清除缓存?",
"dns_clear_cache_yes": "是的,清除",
"dns_config": "DNS 服务配置",
"dns_disallowed_clients": "不允许的客户端",
"dns_disallowed_clients_desc": "丢弃此列表中客户端的请求。当「允许的客户端」中有条目时,此设置将被忽略。",
"dns_disallowed_clients_label": "禁止的客户端,每行一个",
"dns_disallowed_clients_title": "不允许的客户端",
"dns_disallowed_domains": "不允许的域名",
"dns_disallowed_domains_desc": "丢弃匹配这些域名的 DNS 查询,且这些查询不会出现在查询日志中。",
"dns_disallowed_domains_desc_2": "输入精确域名、通配符或 URL 过滤规则,例如 example.org、*.example.org 或 ||example.org^",
"dns_disallowed_domains_label": "禁止的域名,每行一个",
"dns_disallowed_domains_title": "不允许的域名",
"dns_dnssec": "DNSSEC",
"dns_dnssec_desc": "在出站 DNS 查询中添加 DNSSEC 标志并检查结果。需要支持 DNSSEC 的解析器。",
"dns_edns_client_subnet": "EDNS 客户端子网",
"dns_edns_client_subnet_desc": "向上游请求添加 EDNS 客户端子网ECS数据并在查询日志中记录 ECS 值。",
"dns_edns_custom_label": "EDNS 自定义 IP 地址",
"dns_edns_custom_placeholder": "输入 IP 地址",
"dns_edns_desc": "向上游请求添加 EDNS 客户端子网ECS数据并在查询日志中记录 ECS 值。您可以使用默认的客户端子网或为 ECS 指定自定义 IP 地址。",
"dns_edns_option_custom": "使用自定义 IP 地址",
"dns_edns_option_default": "使用默认 EDNS 客户端子网",
"dns_edns_title": "EDNS 客户端子网",
"dns_fallback_dns_desc": "设置当上游 DNS 服务器不可用时使用的备用 DNS 服务器",
"dns_fallback_dns_desc_2": "使用与 <a>上游 DNS 服务器</a> 相同的语法",
"dns_fallback_dns_label": "备用 DNS 服务器,每行一个",
"dns_fallback_dns_placeholder": "IP 地址、sdns://、quic://、https://、h3:// 或 tls://",
"dns_fallback_dns_title": "后备 DNS 服务器",
"dns_fallback_servers": "后备 DNS 服务器",
"dns_ipv6_resolution": "IPv6 解析",
"dns_ipv6_resolution_desc": "启用 IPv6 地址AAAA 记录)的 DNS 查询解析,并向 HTTPS 响应中添加 IPv6 提示。",
"dns_optimistic_caching": "乐观缓存",
"dns_optimistic_caching_desc": "即使缓存条目已过期,也从缓存中响应并尝试更新它们。",
"dns_over_https": "DNS-over-HTTPS",
"dns_over_quic": "DNS-over-QUIC",
"dns_over_tls": "DNS-over-TLS",
"dns_override_max_ttl": "覆盖最大 TTL 值",
"dns_override_max_ttl_desc": "设置 DNS 响应缓存的最大时间。大于此值的 TTL 将被降低到此值。",
"dns_override_max_ttl_label": "最大生存时间TTL单位秒",
"dns_override_max_ttl_placeholder": "生存时间TTL",
"dns_override_max_ttl_title": "覆盖最大 TTL 值",
"dns_override_min_ttl": "覆盖最小 TTL 值",
"dns_override_min_ttl_desc": "设置 DNS 响应缓存的最小时间。小于此值的 TTL 将被提高到此值。",
"dns_override_min_ttl_label": "最小生存时间TTL单位秒",
"dns_override_min_ttl_placeholder": "生存时间TTL",
"dns_override_min_ttl_title": "覆盖最小 TTL 值",
"dns_privacy": "DNS 隐私",
"dns_private_reverse_resolve_clients_desc": "通过 PTR 查询将客户端 IP 地址解析为主机名。本地客户端使用私有 DNS 服务器,公网 IP 客户端使用上游服务器。",
"dns_private_reverse_resolve_clients_title": "客户端 IP 地址反向查询",
"dns_private_reverse_resolvers": "私有反向 DNS 解析器",
"dns_private_reverse_resolvers_desc": "通过私有上游服务器、DHCP 和 /etc/hosts 解析包含私有 IP 地址的 ARPA 域名的 PTR、SOA 和 NS 请求。",
"dns_private_reverse_resolvers_disabled_desc": "禁用后,对所有此类请求响应 NXDOMAIN。",
"dns_private_reverse_servers_desc": "设置用于私有 PTR、SOA 和 NS 请求的 DNS 服务器。这些请求来自具有私有 IP 地址的客户端,查询的 ARPA 域名包含私有 IP 地址范围内的子网。",
"dns_private_reverse_servers_desc_2": "如果此字段为空,将使用操作系统的默认 DNS 解析器AdGuard Home IP 地址除外。默认情况下AdGuard Home 使用以下反向 DNS 解析器:%value_1% 和 %value_2%。",
"dns_private_reverse_servers_label": "私有反向 DNS 服务器,每行一个",
"dns_private_reverse_servers_placeholder": "IP 地址、sdns://、quic://、https://、h3:// 或 tls://",
"dns_private_reverse_servers_title": "私人反向 DNS 服务器",
@ -260,12 +329,25 @@
"dns_query": "DNS 查询",
"dns_rate_limit": "速度限制",
"dns_rate_limit_allowlist": "速率限制白名单",
"dns_rate_limit_allowlist_desc": "指定不受速率限制的 IP 地址",
"dns_rate_limit_allowlist_label": "IP 地址,每行一个",
"dns_rate_limit_allowlist_placeholder": "IP 地址",
"dns_rate_limit_allowlist_title": "速率限制白名单",
"dns_rate_limit_desc": "设置每个客户端每秒允许的请求数",
"dns_rate_limit_desc_2": "值为 0 表示无限制",
"dns_rate_limit_no_limit": "无限制",
"dns_rate_limit_placeholder": "输入限制速率",
"dns_rate_limit_title": "速度限制",
"dns_rate_limit_value": "%value% 请求/秒",
"dns_rewrite_exists": "此 DNS 重写已存在",
"dns_rewrite_removed": "DNS 重写已移除",
"dns_rewrite_same": "不能重写到相同的域名或通配符",
"dns_rewrites": "DNS 重写",
"dns_rewrites_desc": "列出您为特定域名设置的自定义 DNS 响应",
"dns_server_addresses": "服务器地址",
"dns_server_addresses_configured_in_file": "服务器地址已在文件 <strong>%path%</strong> 中配置",
"dns_server_addresses_desc": "设置 AdGuard Home 可通过哪些 DNS 服务器地址访问",
"dns_server_addresses_desc_2": "查看 <a>配置上游 DNS 服务器的提示</a> 以及我们的 <b>已知 DNS 提供商列表</b>",
"dns_server_addresses_label": "服务器地址(每行一个)",
"dns_server_addresses_placeholder": "IP 地址、sdns://、quic://、https://、h3:// 或 tls://",
"dns_server_addresses_title": "服务器地址",
@ -273,9 +355,22 @@
"dns_server_configuration_saved_toast": "DNS 服务器配置已保存",
"dns_settings": "DNS",
"dns_subnet_placeholder": "输入前缀长度",
"dns_subnet_prefix": "%value% 地址的子网前缀长度",
"dns_subnet_prefix_desc": "设置用于速率限制的 %value% 地址的子网前缀长度",
"dns_subnet_prefix_title": "%value% 地址的子网前缀长度",
"dns_test_not_ok_toast": "上游 %key% 通过了语法检查但测试失败。请验证上游设置。",
"dns_test_ok_toast": "所有上游服务器均正常运行",
"dns_test_parsing_error_toast": "无法从 %section% 部分的第 %number% 行提取上游名称。请检查行格式。",
"dns_test_upstreams": "测试上游",
"dns_test_warning_toast": "上游 %key% 未响应 DNS 查询。请验证上游设置。",
"dns_ttl_value": "%value% 秒",
"dns_upstream_mode": "上游模式",
"dns_upstream_mode_desc": "决定如何将网络的 DNS 查询路由到多个上游服务器",
"dns_upstream_mode_title": "上游模式",
"dns_upstream_servers_title": "上游 DNS 服务器",
"dns_upstream_timeout": "上游超时",
"dns_upstream_timeout_desc": "设置等待上游服务器响应的时长",
"dns_upstream_timeout_placeholder": "输入超时时间(秒)",
"dns_upstream_timeout_title": "上游超时",
"dns_upstream_validation_invalid": "格式无效",
"domain": "域名",
@ -284,6 +379,7 @@
"dynamic_dhcp_leases_not_found": "未找到动态 DHCP 租约",
"edit_table_action": "编辑",
"enable": "启用",
"enable_dns_rewrites": "启用 DNS 重写?",
"enabled_dhcp": "DHCP 服务器已启用",
"encrypted_dns_addresses": "加密 DNS 服务器地址",
"encrypted_dns_settings": "加密 DNS 服务器设置",
@ -315,8 +411,10 @@
"encryption_https": "HTTPS 端口",
"encryption_https_summary": "HTTPS 端口:%value%",
"encryption_https_tooltip": "此端口提供 AdGuard Home 管理界面的 HTTPS 访问。同时在 '/dns-query' 路径上提供 DNS-over-HTTPS。",
"encryption_invalid_data": "数据无效",
"encryption_issuer": "颁发者:%value%",
"encryption_key": "私钥",
"encryption_key_cert_mismatch": "私钥与证书不匹配",
"encryption_key_input": "将您以 PEM 格式编码的证书私钥复制粘贴到此处。",
"encryption_key_invalid": "私钥无效",
"encryption_key_source_content": "粘贴私钥内容",
@ -324,6 +422,7 @@
"encryption_key_type": "加密算法:%value%",
"encryption_key_valid": "私钥有效",
"encryption_plain_dns": "无加密 DNS",
"encryption_plain_dns_desc": "纯 DNS 默认启用。禁用它将强制所有设备使用加密 DNS。要禁用纯 DNS请至少指定一个加密 DNS 协议。",
"encryption_plain_dns_error": "要禁用无加密 DNS请至少启用一个加密 DNS 协议",
"encryption_private_key_path": "私钥路径",
"encryption_server": "服务器名称",
@ -333,6 +432,8 @@
"encryption_server_tooltip_2": "必须与证书中的 DNS 名称匹配",
"encryption_subject": "主题:%value%",
"encryption_title": "加密",
"encryption_unable_read_cert": "无法读取证书文件",
"encryption_unable_read_key": "无法读取私钥文件",
"enter_cache_size": "输入缓存大小(字节)",
"enter_ip_address_placeholder": "输入 IP 地址",
"error": "错误",
@ -368,12 +469,14 @@
"form_error_domain_format": "无效的域格式",
"form_error_equal": "不可相同",
"form_error_format": "格式无效",
"form_error_format_line": "第 %line% 行格式无效",
"form_error_format_lines": "第 %lines% 行格式无效",
"form_error_gateway_ip": "租约期限不能有网关的 IP 地址",
"form_error_hostname_format": "请使用数字、小写英文字母或连字符。",
"form_error_ip4_format": "无效的 IPv4 地址",
"form_error_ip6_format": "无效的 IPv6 地址",
"form_error_ip_already_added": "此 IP 地址已添加",
"form_error_ip_format": "无效 IP 地址",
"form_error_ip4_format": "无效的 IPv4 地址",
"form_error_ip6_format": "无效的 IPv6 地址",
"form_error_mac_already_added": "此 MAC 地址已添加",
"form_error_mac_format": "无效的 MAC 地址",
"form_error_password": "密码不匹配",
@ -381,12 +484,17 @@
"form_error_port_range": "输入 80 - 65535 范围内的端口值",
"form_error_port_unsafe": "这是一个不安全的端口",
"form_error_range": "数值必须介于 %min% 和 %max% 之间",
"form_error_required": "请填写此字段",
"form_error_server_name": "无效的服务器名",
"form_error_subnet": "IP 地址应在子网 %cidr% 中",
"form_error_url_format": "输入一个有效的 URL 或文件路径",
"form_value_value_from_error": "输入介于 %min_value% 和 %max_value% 之间的值",
"form_value_value_min_error": "输入 %min_value% 或更大的值",
"found_in_known_domains": "在已知域名中找到",
"friday": "星期五",
"gateway_or_subnet_invalid": "子网掩码无效",
"general_statistics": "概况统计",
"greater_range_start_error": "范围的结束地址必须大于起始地址",
"home_dns_addresses": "AdGuard Home DNS 地址",
"home_dns_addresses_desc": "AdGuard Home DNS 服务器监听以下接口/端口:",
"ignore_domains_desc_log": "匹配这些规则的请求不会显示在「查询日志」中",
@ -430,6 +538,7 @@
"light_theme": "浅色主题",
"list_updated": "%count% 个列表已更新 | %count% 个列表已更新",
"login": "登录",
"logs": "日志",
"logout": "退出",
"make_static": "静态化",
"make_static_desc": "此操作将动态地址转换为静态地址",
@ -448,6 +557,7 @@
"notify_undo": "撤销",
"notify_updated": "已更新",
"open_dashboard": "打开仪表盘",
"out_of_range_error": "地址不能在 DHCP 范围 %start%%end% 内",
"parental_control": "家长控制",
"parental_group_ai": "人工智能",
"parental_group_cdn": "内容分发网络",
@ -465,6 +575,7 @@
"parental_group_software": "软件开发",
"parental_group_streaming": "串流",
"password_label": "密码",
"password_login_error": "用户名和密码不匹配",
"password_placeholder": "输入密码",
"password_requirements": "密码要求",
"password_requirements_characters": "至少 8 个字符",
@ -472,6 +583,7 @@
"password_requirements_match": "密码匹配",
"password_requirements_special": "英文字母、数字和特殊字符",
"password_requirements_uppercase": "至少 1 个大写字母",
"path_to_file_placeholder": "文件路径:本地或网络地址",
"pause_for_hour": "暂停 %count% 小时",
"pause_for_minutes": "暂停 %count% 分钟 | 暂停 %count% 分钟",
"pause_for_seconds": "暂停 %count% 秒 | 暂停 %count% 秒",
@ -487,6 +599,7 @@
"protocol": "协议",
"protocols": "协议",
"queries": "查询",
"queries_tooltip": "%value% 条查询",
"queries_total": "共 %value% 条查询 | 共 %value% 条查询",
"query_details": "查询详情",
"query_log": "查询日志",
@ -522,6 +635,7 @@
"query_log_detail_time": "时间:<span>%value%</span>",
"query_log_detail_type": "类型:<span>%value%</span>",
"query_log_nothing_available": "没找到",
"query_log_nothing_available_rotation": "查询日志已在「常规设置」中禁用",
"query_log_processed": "已处理",
"query_log_retention": "日志轮替",
"query_log_rewritten": "重写项",
@ -539,6 +653,8 @@
"request_table_header": "请求",
"requests_table_header": "请求",
"reset": "重置",
"reset_dhcp_settings": "重置 DHCP 设置",
"reset_dns_confirm_text": "所有 DNS 协议设置将重置为默认值",
"reset_dns_confirm_title": "重置 DNS 协议?",
"reset_dns_protocols": "重置 DNS 协议",
"reset_settings": "重置设置",
@ -548,6 +664,7 @@
"result": "结果",
"resume_protection_timer": "防护将在 %time% 恢复",
"rewrite_add": "添加 DNS 重写",
"rewrite_domain": "域名或通配符",
"rewrite_domain_input_placeholder": "输入域名或通配符",
"rewrite_edit": "编辑 DNS 重写",
"rewrite_hosts_applied": "根据 hosts 文件规则已被重写",
@ -574,6 +691,7 @@
"save": "保存",
"save_btn": "保存",
"search_placeholder": "搜索",
"select_language": "选择语言",
"selected": "已选择 %value%",
"server_config_blocking_mode": "拦截模式",
"server_config_blocking_mode_custom_ip_desc": "<strong>自定义 IP 地址</strong>:响应手动设置的 IP 地址",
@ -613,6 +731,7 @@
"server_config_subnet_len_ipv6_faq": "用于速率限制的 IPv6 地址子网前缀长度。默认为 56",
"server_config_subnet_len_placeholder": "输入前缀长度",
"set_static_ip": "设置一个静态 IP",
"set_static_ip_manually": "设置静态 IP 地址",
"settings": "设置",
"settings_anonymize_client_ip": "对客户端 IP 地址进行匿名化处理",
"settings_anonymize_client_ip_desc": "将 IP 地址的一部分替换为零",
@ -632,7 +751,7 @@
"settings_filter_requests": "过滤请求",
"settings_filter_requests_desc": "您可以使用<a>拦截列表</a>、<b>允许列表</b>和<c>用户规则</c>来设置过滤规则。",
"settings_filtering_and_security": "过滤与安全",
"settings_general_short": "General",
"settings_general_short": "常规",
"settings_global": "全局",
"settings_hours": "%count% 个小时 | %count% 个小时",
"settings_log_dns_requests": "记录 DNS 请求",
@ -736,6 +855,7 @@
"stats_adult": "被拦截的成人网站",
"stats_query_domain": "请求域名排行",
"status_table_header": "状态",
"subnet_error": "范围地址必须在同一子网内",
"sunday": "星期日",
"system_host_files": "系统主机文件",
"system_theme": "系统主题",
@ -746,7 +866,14 @@
"threats_blocked_tooltip": "被 AdGuard 浏览安全模块拦截的 DNS 查询数量",
"thursday": "星期四",
"time_table_header": "时间",
"tls_cert_modal_description": "要使用加密,您必须为您的域名提供有效的 TLS 证书链。您可以从 letsencrypt.org 获取免费 TLS 证书,或从受信任的证书颁发机构购买。",
"tls_cert_path_label": "证书文件的完整路径",
"tls_cert_path_option": "输入证书文件的完整路径",
"tls_certificate": "TLS 证书",
"tls_certificate_expired": "您的 TLS 证书已过期",
"tls_certificate_expiring": "您的 TLS 证书即将过期",
"tls_key_path_label": "私钥文件的完整路径",
"tls_key_path_option": "输入私钥文件的完整路径",
"top_blocked_domains": "被拦截域名排行",
"top_clients": "客户端排行",
"top_upstreams": "经常请求的上游服务器",
@ -759,14 +886,18 @@
"unblock": "取消拦截",
"unblock_client": "解除拦截客户端",
"unknown_filter": "未知过滤器 %filterId%",
"update_available": "版本 %version% 可用。<a>发布说明</a>",
"update_button": "更新",
"update_failed": "自动更新失败。请 <a>按此步骤</a> 手动更新。",
"update_filters_custom_hours": "自定义间隔(小时)",
"update_filters_desc": "设置过滤器的自动更新间隔。适用于通过外部链接添加的拦截列表和允许列表。",
"update_filters_disable": "禁用",
"update_filters_interval_custom": "用户",
"update_filters_interval_daily": "每天",
"update_filters_interval_hourly": "每小时",
"update_filters_interval_weekly": "每周",
"update_filters_title": "自动更新过滤器",
"update_how_to": "如何更新",
"updated_upstream_dns_toast": "上游服务器已保存",
"updates_checked": "AdGuard Home 的新版本现在可用",
"updates_version_equal": "AdGuard Home已经是最新版本",
@ -778,7 +909,12 @@
"upstream_dns_addresses_faq": "<a>了解更多</a>关于上游 DNS 服务器配置的信息。以下为<b>已知 DNS 提供商列表</b>供您选择。",
"upstream_dns_configured_in_file": "配置于 %path%",
"upstream_dns_fastest_addr": "最快的 IP 地址",
"upstream_dns_fastest_addr_desc": "等待所有 DNS 服务器响应,测量每个服务器的 TCP 连接速度,并返回连接速度最快的服务器的 IP 地址。",
"upstream_dns_fastest_addr_warning": "如果一个或多个上游服务器没有响应,此模式可能会显著降低 DNS 查询速度。请确保上游服务器稳定且上游超时时间设置较低。",
"upstream_dns_load_balancing": "负载均衡",
"upstream_dns_load_balancing_desc": "<p>每次查询一个上游服务器</p><p>AdGuard Home 使用加权随机算法选择失败查询次数最少且平均查询时间最低的服务器</p>",
"upstream_dns_parallel_requests": "并行请求",
"upstream_dns_parallel_requests_desc": "使用并行查询以加速解析,同时查询所有上游服务器",
"upstream_dns_placeholder": "IP 地址、sdns://、quic://、https://、h3:// 或 tls://",
"upstream_dns_servers_title": "上游 DNS 服务器",
"upstream_enable_reverse_lookup_desc": "通过向适当的解析器发送 PTR 查询,将客户端 IP 地址反向解析为主机名:本地客户端使用私有 DNS 服务器,公网 IP 地址客户端使用上游服务器。",
@ -824,6 +960,7 @@
"user_rules_check_hostname_placeholder": "输入目标",
"user_rules_check_title": "检查全局或针对特定客户端的域名过滤",
"user_rules_cname": "<strong>CNAME</strong>%cname%",
"user_rules_desc": "每行输入一条规则。使用 Adblock 风格或 hosts 文件语法。",
"user_rules_disable_browsing_security": "禁用浏览安全",
"user_rules_disable_filter": "禁用过滤器",
"user_rules_disable_parental_control": "停用家长控制",
@ -856,6 +993,7 @@
"user_rules_rewrite_rule_is_applied": " 重定向规则已应用",
"user_rules_rewritten_to": "<strong>已重写为</strong>%value%",
"user_rules_rule": "<strong>规则</strong>%rule%",
"user_rules_rule_added": "已添加用户规则:<strong>%rule%</strong>",
"user_rules_rule_added_to_custom_filtering_rules": "规则已添加到自定义过滤规则",
"user_rules_rule_removed": "规则已从自定义过滤规则中移除",
"user_rules_safe_search_disabled": "安全搜索已禁用",
@ -869,9 +1007,12 @@
"username_label": "用户名",
"username_placeholder": "输入用户名",
"validated_with_dnssec": "通过 DNSSEC 验证",
"version_number": "版本 %value%",
"wednesday": "星期三",
"whois": "WHOIS",
"yes": "是",
"yes_disable": "禁用",
"yes_disallow": "是的,禁止",
"yes_remove": "是的,移除",
"yes_reset": "重置"
}

View file

@ -1,36 +1,34 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mocks = vi.hoisted(() => ({
getAccessList: vi.fn(),
setAccessList: vi.fn(),
accessList: vi.fn(),
accessSet: vi.fn(),
addSuccessToast: vi.fn(),
addErrorToast: vi.fn(),
}));
vi.mock('panel/api/Api', () => ({
apiClient: {
getAccessList: mocks.getAccessList,
setAccessList: mocks.setAccessList,
},
vi.mock('panel/api/generated', () => ({
accessList: mocks.accessList,
accessSet: mocks.accessSet,
}));
vi.mock('panel/stores/toasts', () => ({
addSuccessToast: mocks.addSuccessToast,
addErrorToast: mocks.addErrorToast,
}));
import { toggleClientBlock } from 'panel/stores/access';
import { toggleClientBlock, setAccessList } from 'panel/stores/access';
describe('toggleClientBlock', () => {
beforeEach(() => vi.clearAllMocks());
it('not-disallowed + allowlist mode with >1 allowed → removes from allowed', async () => {
mocks.getAccessList.mockResolvedValue({
mocks.accessList.mockResolvedValue({
allowed_clients: ['1.1.1.1', '2.2.2.2'],
disallowed_clients: [],
blocked_hosts: [],
});
await toggleClientBlock('1.1.1.1', false, '');
expect(mocks.setAccessList).toHaveBeenCalledWith({
expect(mocks.accessSet).toHaveBeenCalledWith({
allowed_clients: ['2.2.2.2'],
disallowed_clients: [],
blocked_hosts: [],
@ -38,13 +36,13 @@ describe('toggleClientBlock', () => {
});
it('not-disallowed, no allowlist → adds to disallowed', async () => {
mocks.getAccessList.mockResolvedValue({
mocks.accessList.mockResolvedValue({
allowed_clients: [],
disallowed_clients: [],
blocked_hosts: [],
});
await toggleClientBlock('3.3.3.3', false, '');
expect(mocks.setAccessList).toHaveBeenCalledWith({
expect(mocks.accessSet).toHaveBeenCalledWith({
allowed_clients: [],
disallowed_clients: ['3.3.3.3'],
blocked_hosts: [],
@ -52,13 +50,13 @@ describe('toggleClientBlock', () => {
});
it('disallowed + allowlist mode → adds to allowed', async () => {
mocks.getAccessList.mockResolvedValue({
mocks.accessList.mockResolvedValue({
allowed_clients: ['1.1.1.1'],
disallowed_clients: ['2.2.2.2'],
blocked_hosts: [],
});
await toggleClientBlock('2.2.2.2', true, '');
expect(mocks.setAccessList).toHaveBeenCalledWith({
expect(mocks.accessSet).toHaveBeenCalledWith({
allowed_clients: ['1.1.1.1', '2.2.2.2'],
disallowed_clients: ['2.2.2.2'],
blocked_hosts: [],
@ -66,16 +64,51 @@ describe('toggleClientBlock', () => {
});
it('disallowed, no allowlist → removes from disallowed (uses rule)', async () => {
mocks.getAccessList.mockResolvedValue({
mocks.accessList.mockResolvedValue({
allowed_clients: [],
disallowed_clients: ['client:X'],
blocked_hosts: [],
});
await toggleClientBlock('1.2.3.4', true, 'client:X');
expect(mocks.setAccessList).toHaveBeenCalledWith({
expect(mocks.accessSet).toHaveBeenCalledWith({
allowed_clients: [],
disallowed_clients: [],
blocked_hosts: [],
});
});
});
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

@ -45,7 +45,7 @@ vi.mock('panel/common/ui/Footer', () => ({
Footer: () => <div data-testid="chrome-footer" />,
}));
vi.mock('panel/common/ui/Icons', () => ({ Icons: (): null => null }));
vi.mock('panel/components/Toasts', () => ({ default: (): null => null }));
vi.mock('panel/components/Toasts', () => ({ Toasts: (): null => null }));
// Deterministic marker so the assertion does not depend on Dashboard data/i18n.
vi.mock('panel/components/Dashboard', () => ({

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

@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { LOCALES, LOCALE_LOADERS, LOCALE_CODES } from 'panel/common/intl/locales.generated';
import twosky from 'Twosky';
const homeV2 = twosky.find((p) => p.project_id === 'home_v2')!;
describe('locales.generated.ts', () => {
const allTwoskyCodes = Object.keys(homeV2.languages);
const expectedCodes = allTwoskyCodes.sort();
it('LOCALE_CODES lists every home_v2 language', () => {
expect([...LOCALE_CODES].sort()).toEqual(expectedCodes);
});
it('LOCALE_LOADERS has a loader for every non-en locale', () => {
const loaderCodes = Object.keys(LOCALE_LOADERS).sort();
expect(loaderCodes).toEqual(expectedCodes.filter((c) => c !== 'en'));
});
it('LOCALES only contains the base locale (en) initially', () => {
expect(Object.keys(LOCALES)).toEqual(['en']);
});
it('base locale (en) is present and non-empty', () => {
expect(LOCALES.en).toBeDefined();
expect(Object.keys(LOCALES.en).length).toBeGreaterThan(0);
});
it('every lazy loader resolves to a non-empty message map', async () => {
for (const [code, loader] of Object.entries(LOCALE_LOADERS)) {
const mod = await loader();
const messages = (mod as { default?: Record<string, string> }).default ?? mod;
expect(messages, `locale ${code}`).toBeDefined();
expect(
Object.keys(messages as Record<string, string>).length,
`locale ${code}`,
).toBeGreaterThan(0);
}
});
});

View file

@ -1,22 +1,21 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mocks = vi.hoisted(() => ({
getTlsStatus: vi.fn(),
getGlobalVersion: vi.fn(),
tlsStatus: vi.fn(),
getVersionJson: vi.fn(),
getProfile: vi.fn(),
addErrorToast: vi.fn(),
addSuccessToast: vi.fn(),
addNoticeToast: vi.fn(),
}));
vi.mock('panel/api/Api', () => ({
apiClient: {
baseUrl: 'http://x',
getGlobalVersion: mocks.getGlobalVersion,
getProfile: mocks.getProfile,
getUpdate: vi.fn(),
getTlsStatus: mocks.getTlsStatus,
},
vi.mock('panel/api/generated', () => ({
baseUrl: 'http://x',
getStatusUrl: () => 'control/status',
getVersionJson: mocks.getVersionJson,
getProfile: mocks.getProfile,
beginUpdate: vi.fn(),
tlsStatus: mocks.tlsStatus,
}));
vi.mock('panel/stores/toasts', () => ({
addErrorToast: mocks.addErrorToast,
@ -30,12 +29,12 @@ describe('getDnsStatus', () => {
beforeEach(() => vi.clearAllMocks());
it('fetches TLS status when the core is running', async () => {
mocks.getGlobalVersion.mockResolvedValue({
mocks.getVersionJson.mockResolvedValue({
disabled: true,
new_version: 'x',
});
mocks.getProfile.mockResolvedValue({ name: 'n', theme: 't' });
mocks.getTlsStatus.mockResolvedValue({ enabled: false });
mocks.tlsStatus.mockResolvedValue({ enabled: false });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
@ -54,6 +53,6 @@ describe('getDnsStatus', () => {
await getDnsStatus();
// allow microtasks for chained getVersion/getTlsStatus/getProfile
await new Promise((r) => setTimeout(r, 0));
expect(mocks.getTlsStatus).toHaveBeenCalled();
expect(mocks.tlsStatus).toHaveBeenCalled();
});
});

View file

@ -1,22 +1,20 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mocks = vi.hoisted(() => ({
findActiveDhcp: vi.fn(),
getDhcpInterfaces: vi.fn(),
getDhcpStatus: vi.fn(),
setDhcpConfig: vi.fn(),
checkActiveDhcp: vi.fn(),
dhcpInterfaces: vi.fn(),
dhcpStatus: vi.fn(),
dhcpSetConfig: vi.fn(),
addErrorToast: vi.fn(),
addSuccessToast: vi.fn(),
}));
vi.mock('panel/api/Api', () => ({
apiClient: {
findActiveDhcp: mocks.findActiveDhcp,
getDhcpInterfaces: mocks.getDhcpInterfaces,
getDhcpStatus: mocks.getDhcpStatus,
getGlobalStatus: vi.fn(),
setDhcpConfig: mocks.setDhcpConfig,
},
vi.mock('panel/api/generated', () => ({
checkActiveDhcp: mocks.checkActiveDhcp,
dhcpInterfaces: mocks.dhcpInterfaces,
dhcpStatus: mocks.dhcpStatus,
status: vi.fn(),
dhcpSetConfig: mocks.dhcpSetConfig,
}));
vi.mock('panel/stores/toasts', () => ({
addErrorToast: mocks.addErrorToast,
@ -29,19 +27,19 @@ describe('findActiveDhcp', () => {
beforeEach(() => vi.clearAllMocks());
it('passes { interface } not a bare string', async () => {
mocks.findActiveDhcp.mockResolvedValue({
mocks.checkActiveDhcp.mockResolvedValue({
v4: { other_server: { found: 'yes' }, static_ip: { static: 'yes' } },
v6: { other_server: {} },
});
await findActiveDhcp('eth0');
expect(mocks.findActiveDhcp).toHaveBeenCalledWith({ interface: 'eth0' });
expect(mocks.checkActiveDhcp).toHaveBeenCalledWith({ interface: 'eth0' });
});
it('shows dhcp_found error with retry action when another DHCP server detected', async () => {
mocks.getDhcpInterfaces.mockResolvedValue({
mocks.dhcpInterfaces.mockResolvedValue({
eth0: { ipv4_addresses: ['1.1.1.1'], ipv6_addresses: [] },
});
mocks.findActiveDhcp.mockResolvedValue({
mocks.checkActiveDhcp.mockResolvedValue({
v4: {
other_server: { found: 'yes' },
static_ip: { static: 'yes', ip: 'x' },
@ -58,10 +56,10 @@ describe('findActiveDhcp', () => {
});
it('shows dhcp_not_found success toast when clean', async () => {
mocks.getDhcpInterfaces.mockResolvedValue({
mocks.dhcpInterfaces.mockResolvedValue({
eth0: { ipv4_addresses: ['1.1.1.1'], ipv6_addresses: [] },
});
mocks.findActiveDhcp.mockResolvedValue({
mocks.checkActiveDhcp.mockResolvedValue({
v4: {
other_server: { found: 'no' },
static_ip: { static: 'yes', ip: '1.1.1.1' },
@ -77,12 +75,18 @@ describe('findActiveDhcp', () => {
describe('setDhcpConfig', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.setDhcpConfig.mockResolvedValue(undefined);
mocks.dhcpSetConfig.mockResolvedValue(undefined);
});
it('shows dhcp_config_saved toast', async () => {
await setDhcpConfig({
v4: { range_start: 'a', range_end: 'b' },
v4: {
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,
},
interface_name: 'eth0',
});
expect(mocks.addSuccessToast).toHaveBeenCalled();
@ -92,12 +96,12 @@ describe('setDhcpConfig', () => {
describe('toggleDhcp', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.setDhcpConfig.mockResolvedValue(undefined);
mocks.dhcpSetConfig.mockResolvedValue(undefined);
});
it('computes enabled from passed config, not current state', async () => {
await toggleDhcp({ enabled: false, interface_name: 'eth0' });
expect(mocks.setDhcpConfig).toHaveBeenCalledWith(
expect(mocks.dhcpSetConfig).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true }),
);
});

View file

@ -9,6 +9,8 @@ const mocks = vi.hoisted(() => ({
interface_name: '',
processingDhcp: false,
processingConfig: false,
v4: { gateway_ip: '', subnet_mask: '', range_start: '', range_end: '', lease_duration: 0 },
v6: { range_start: '', lease_duration: 0 },
},
}));
@ -28,18 +30,51 @@ describe('DhcpToggle', () => {
interface_name: 'eth0',
processingDhcp: false,
processingConfig: false,
v4: { gateway_ip: '', subnet_mask: '', range_start: '', range_end: '', lease_duration: 0 },
v6: { range_start: '', lease_duration: 0 },
};
const { container } = render(() => <DhcpToggle selectedInterface={() => 'eth0'} />);
const input = container.querySelector('#dhcp_enabled') as HTMLInputElement;
expect(input.checked).toBe(true);
});
it('calls toggleDhcp with enabled:false + interface_name when toggled ON + calls onToggleOn', () => {
it('when toggled ON without v4 config, reverts UI and calls onToggleOn without toggleDhcp', () => {
mocks.dhcpState = {
enabled: false,
interface_name: 'eth0',
processingDhcp: false,
processingConfig: false,
v4: { gateway_ip: '', subnet_mask: '', range_start: '', range_end: '', lease_duration: 0 },
v6: { range_start: '', lease_duration: 0 },
};
const onToggleOn = vi.fn();
const { container } = render(() => (
<DhcpToggle selectedInterface={() => 'eth0'} onToggleOn={onToggleOn} />
));
const input = container.querySelector('#dhcp_enabled') as HTMLInputElement;
fireEvent.change(input, { target: { checked: true } });
// Shadow signal reverts — the switch should appear unchecked.
expect(input.checked).toBe(false);
// Backend must NOT be called — config is not yet filled.
expect(mocks.toggleDhcp).not.toHaveBeenCalled();
// Config modal should open so the user can fill v4 settings.
expect(onToggleOn).toHaveBeenCalledOnce();
});
it('when toggled ON with existing v4 config, calls toggleDhcp without onToggleOn', () => {
mocks.dhcpState = {
enabled: false,
interface_name: 'eth0',
processingDhcp: false,
processingConfig: false,
v4: {
gateway_ip: '192.168.1.1',
subnet_mask: '255.255.255.0',
range_start: '192.168.1.50',
range_end: '192.168.1.100',
lease_duration: 86400,
},
v6: { range_start: '', lease_duration: 0 },
};
const onToggleOn = vi.fn();
const { container } = render(() => (
@ -51,9 +86,11 @@ describe('DhcpToggle', () => {
expect.objectContaining({
enabled: false,
interface_name: 'eth0',
v4: expect.objectContaining({ gateway_ip: '192.168.1.1' }),
}),
);
expect(onToggleOn).toHaveBeenCalledOnce();
// v4 is configured — no need to open the config modal.
expect(onToggleOn).not.toHaveBeenCalled();
});
it('calls toggleDhcp with enabled:true when toggled OFF, does NOT call onToggleOn', () => {
@ -62,6 +99,8 @@ describe('DhcpToggle', () => {
interface_name: 'eth0',
processingDhcp: false,
processingConfig: false,
v4: { gateway_ip: '', subnet_mask: '', range_start: '', range_end: '', lease_duration: 0 },
v6: { range_start: '', lease_duration: 0 },
};
const onToggleOn = vi.fn();
const { container } = render(() => (
@ -79,6 +118,8 @@ describe('DhcpToggle', () => {
interface_name: '',
processingConfig: true,
processingDhcp: false,
v4: { gateway_ip: '', subnet_mask: '', range_start: '', range_end: '', lease_duration: 0 },
v6: { range_start: '', lease_duration: 0 },
};
const { container } = render(() => <DhcpToggle selectedInterface={() => ''} />);
const input = container.querySelector('#dhcp_enabled') as HTMLInputElement;

View file

@ -1,17 +1,15 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mocks = vi.hoisted(() => ({
getDnsConfig: vi.fn(),
setDnsConfig: vi.fn(),
dnsInfo: vi.fn(),
dnsConfig: vi.fn(),
addErrorToast: vi.fn(),
addSuccessToast: vi.fn(),
}));
vi.mock('panel/api/Api', () => ({
apiClient: {
getDnsConfig: mocks.getDnsConfig,
setDnsConfig: mocks.setDnsConfig,
},
vi.mock('panel/api/generated', () => ({
dnsInfo: mocks.dnsInfo,
dnsConfig: mocks.dnsConfig,
}));
vi.mock('panel/stores/toasts', () => ({
addErrorToast: mocks.addErrorToast,
@ -27,7 +25,7 @@ describe('getDnsConfig', () => {
beforeEach(() => vi.clearAllMocks());
it('defaults null blocking IPs and empty upstream_mode', async () => {
mocks.getDnsConfig.mockResolvedValue({
mocks.dnsInfo.mockResolvedValue({
blocking_ipv4: null,
blocking_ipv6: null,
upstream_mode: '',
@ -43,7 +41,7 @@ describe('toggleResolveClients', () => {
beforeEach(() => vi.clearAllMocks());
it('toggles resolve_clients and persists', async () => {
mocks.setDnsConfig.mockResolvedValue({});
mocks.dnsConfig.mockResolvedValue({});
const before = dnsConfigState.resolve_clients;
await toggleResolveClients();
@ -54,12 +52,12 @@ describe('toggleResolveClients', () => {
expect(dnsConfigState.resolve_clients).toBe(before);
});
it('calls apiClient.setDnsConfig with inverted value', async () => {
mocks.setDnsConfig.mockResolvedValue({});
it('calls dnsConfig with inverted value', async () => {
mocks.dnsConfig.mockResolvedValue({});
const before = dnsConfigState.resolve_clients;
await toggleResolveClients();
expect(mocks.setDnsConfig).toHaveBeenCalledWith({
expect(mocks.dnsConfig).toHaveBeenCalledWith({
resolve_clients: !before,
});
});

View file

@ -1,19 +1,17 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mocks = vi.hoisted(() => ({
setTlsConfig: vi.fn(),
validateTlsConfig: vi.fn(),
tlsConfigure: vi.fn(),
tlsValidate: vi.fn(),
addErrorToast: vi.fn(),
addSuccessToast: vi.fn(),
redirectToCurrentProtocol: vi.fn(),
}));
vi.mock('panel/api/Api', () => ({
apiClient: {
setTlsConfig: mocks.setTlsConfig,
validateTlsConfig: mocks.validateTlsConfig,
getGlobalStatus: vi.fn(),
},
vi.mock('panel/api/generated', () => ({
tlsConfigure: mocks.tlsConfigure,
tlsValidate: mocks.tlsValidate,
status: vi.fn(),
}));
vi.mock('panel/stores/toasts', () => ({
addErrorToast: mocks.addErrorToast,
@ -38,7 +36,7 @@ describe('setTlsConfig', () => {
beforeEach(() => vi.clearAllMocks());
it('defaults empty ports to 0', async () => {
mocks.setTlsConfig.mockImplementation(async (v: any) => ({
mocks.tlsConfigure.mockImplementation(async (v: any) => ({
...v,
certificate_chain: '',
private_key: '',
@ -46,18 +44,18 @@ 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.setTlsConfig.mock.calls[0][0];
const sent = mocks.tlsConfigure.mock.calls[0][0];
expect(sent.port_https).toBe(0);
expect(sent.port_dns_over_tls).toBe(0);
expect(sent.port_dns_over_quic).toBe(0);
});
it('clears validation status fields when resetValidationStatus is called', async () => {
mocks.validateTlsConfig.mockResolvedValue({
mocks.tlsValidate.mockResolvedValue({
valid_chain: true,
valid_cert: true,
valid_key: true,
@ -91,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();
});
@ -100,7 +98,7 @@ describe('setTlsConfig', () => {
value: { protocol: 'http:' },
writable: true,
});
mocks.setTlsConfig.mockImplementation(async (v: any) => ({
mocks.tlsConfigure.mockImplementation(async (v: any) => ({
...v,
certificate_chain: '',
private_key: '',
@ -127,7 +125,7 @@ describe('setTlsConfig', () => {
value: { protocol: 'https:' },
writable: true,
});
mocks.setTlsConfig.mockImplementation(async (v: any) => ({
mocks.tlsConfigure.mockImplementation(async (v: any) => ({
...v,
certificate_chain: '',
private_key: '',
@ -148,7 +146,7 @@ describe('setTlsConfig', () => {
value: { protocol: 'https:' },
writable: true,
});
mocks.setTlsConfig.mockImplementation(async (v: any) => ({
mocks.tlsConfigure.mockImplementation(async (v: any) => ({
...v,
certificate_chain: '',
private_key: '',

View file

@ -15,12 +15,10 @@ const mocks = vi.hoisted(() => ({
addErrorToast: vi.fn(),
}));
vi.mock('panel/api/Api', () => ({
apiClient: {
setRules: mocks.apiSetRules,
addFilter: mocks.apiAddFilter,
getFilteringStatus: mocks.apiGetFilteringStatus,
},
vi.mock('panel/api/generated', () => ({
filteringSetRules: mocks.apiSetRules,
filteringAddURL: mocks.apiAddFilter,
filteringStatus: mocks.apiGetFilteringStatus,
}));
vi.mock('panel/stores/toasts', () => ({

View file

@ -15,12 +15,10 @@ const mocks = vi.hoisted(() => ({
addErrorToast: vi.fn(),
}));
vi.mock('panel/api/Api', () => ({
apiClient: {
setRules: mocks.apiSetRules,
addFilter: mocks.apiAddFilter,
getFilteringStatus: mocks.apiGetFilteringStatus,
},
vi.mock('panel/api/generated', () => ({
filteringSetRules: mocks.apiSetRules,
filteringAddURL: mocks.apiAddFilter,
filteringStatus: mocks.apiGetFilteringStatus,
}));
vi.mock('panel/stores/toasts', () => ({

View file

@ -0,0 +1,85 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { getBrowserLanguage } from '../../helpers/helpers';
// Mock the twosky LANGUAGES map to keep tests deterministic
vi.mock('../../helpers/twosky', () => ({
LANGUAGES: {
en: 'English',
de: 'Deutsch',
fr: 'Français',
'zh-cn': '简体中文',
'pt-br': 'Português (BR)',
},
LANGUAGE_NAMES: {
en: 'English',
de: 'Deutsch',
fr: 'Français',
'zh-cn': '简体中文',
'pt-br': 'Português (BR)',
},
BASE_LOCALE: 'en',
}));
const { LocalStorageHelper } = await import('../../helpers/localStorageHelper');
const getStoredItemSpy = vi.spyOn(LocalStorageHelper, 'getItem');
const navigatorSpy = vi.spyOn(globalThis.navigator, 'language', 'get');
afterEach(() => {
getStoredItemSpy.mockReset();
navigatorSpy.mockReset();
});
describe('getBrowserLanguage', () => {
describe('localStorage takes priority', () => {
it('returns stored language when it matches a supported locale', () => {
getStoredItemSpy.mockReturnValue('de');
expect(getBrowserLanguage()).toBe('de');
});
it('falls past localStorage when the stored code is unsupported', () => {
getStoredItemSpy.mockReturnValue('xx');
navigatorSpy.mockReturnValue('fr');
expect(getBrowserLanguage()).toBe('fr');
});
});
describe('browser language detection', () => {
it('matches full locale code like zh-cn', () => {
navigatorSpy.mockReturnValue('zh-CN');
expect(getBrowserLanguage()).toBe('zh-cn');
});
it('matches base language when full locale is unsupported', () => {
navigatorSpy.mockReturnValue('fr-FR');
expect(getBrowserLanguage()).toBe('fr');
});
it('matches base language from pt-BR', () => {
navigatorSpy.mockReturnValue('pt-BR');
// "pt-br" is a supported full locale, so it matches directly
expect(getBrowserLanguage()).toBe('pt-br');
});
it('falls back to base language for unsupported region', () => {
navigatorSpy.mockReturnValue('de-AT');
// "de-at" is NOT in LANGUAGES, but base "de" is
expect(getBrowserLanguage()).toBe('de');
});
});
describe('fallback to en', () => {
it('returns en when localStorage is empty and navigator is missing', () => {
getStoredItemSpy.mockReturnValue(null);
navigatorSpy.mockReturnValue('');
expect(getBrowserLanguage()).toBe('en');
});
it('returns en when both sources yield unsupported codes', () => {
getStoredItemSpy.mockReturnValue('yy');
navigatorSpy.mockReturnValue('zz-ZZ');
expect(getBrowserLanguage()).toBe('en');
});
});
});

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

@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import { normalizeServerName } from 'panel/helpers/form';
describe('normalizeServerName', () => {
it('returns plain hostname unchanged', () => {
expect(normalizeServerName('example.com')).toBe('example.com');
});
it('returns subdomain unchanged', () => {
expect(normalizeServerName('dns.example.com')).toBe('dns.example.com');
});
it('strips trailing dot (FQDN notation)', () => {
expect(normalizeServerName('example.com.')).toBe('example.com');
});
it('trims leading and trailing whitespace', () => {
expect(normalizeServerName(' example.com ')).toBe('example.com');
});
it('strips https:// prefix', () => {
expect(normalizeServerName('https://example.com')).toBe('example.com');
});
it('strips http:// prefix', () => {
expect(normalizeServerName('http://example.com')).toBe('example.com');
});
it('strips trailing slash', () => {
expect(normalizeServerName('https://example.com/')).toBe('example.com');
});
it('strips all three: protocol, trailing slash, and trailing dot', () => {
expect(normalizeServerName(' https://example.com/. ')).toBe('example.com');
});
it('returns empty string for empty input', () => {
expect(normalizeServerName('')).toBe('');
});
it('returns empty string for whitespace-only input', () => {
expect(normalizeServerName(' ')).toBe('');
});
it('returns empty string for a bare dot', () => {
expect(normalizeServerName('.')).toBe('');
});
// ── No silent rewriting beyond protocol and trailing slash ─────
it('does NOT strip port', () => {
expect(normalizeServerName('example.com:443')).toBe('example.com:443');
});
it('does NOT strip path', () => {
expect(normalizeServerName('example.com/dns-query')).toBe('example.com/dns-query');
});
it('does NOT strip query string', () => {
expect(normalizeServerName('example.com?param=1')).toBe('example.com?param=1');
});
it('does NOT strip fragment', () => {
expect(normalizeServerName('example.com#section')).toBe('example.com#section');
});
it('preserves single-label hostname (localhost)', () => {
expect(normalizeServerName('localhost')).toBe('localhost');
});
it('preserves IDN hostname', () => {
expect(normalizeServerName('münchen.example.com')).toBe('münchen.example.com');
});
it('preserves IPv4 address', () => {
expect(normalizeServerName('192.168.1.1')).toBe('192.168.1.1');
});
it('preserves hostname with hyphens', () => {
expect(normalizeServerName('my-dns-host.example.com')).toBe('my-dns-host.example.com');
});
it('preserves hostname with digits', () => {
expect(normalizeServerName('dns01.example.com')).toBe('dns01.example.com');
});
it('preserves hostname containing "https" text', () => {
expect(normalizeServerName('https-only.example.com')).toBe('https-only.example.com');
});
});

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

@ -2,16 +2,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the API client.
const mockCheckConfig = vi.fn();
vi.mock('panel/api/Api', () => ({
apiClient: {
checkConfig: (...args: unknown[]) => mockCheckConfig(...args),
getDefaultAddresses: vi.fn(),
setAllSettings: vi.fn(),
},
vi.mock('panel/api/generated', () => ({
installCheckConfig: (...args: unknown[]) => mockCheckConfig(...args),
installGetAddresses: vi.fn(),
installConfigure: vi.fn(),
}));
vi.mock('panel/stores/toasts', () => ({ addErrorToast: vi.fn() }));
vi.mock('panel/helpers/constants', () => ({
vi.mock('panel/helpers/constants', async (importOriginal) => ({
...(await importOriginal<typeof import('panel/helpers/constants')>()),
ALL_INTERFACES_IP: '0.0.0.0',
INSTALL_FIRST_STEP: 1,
STANDARD_DNS_PORT: 53,

View file

@ -1,6 +1,18 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { translate } from '@adguard/translate';
import { createSolidDefaultValues, solidMessageConstructor } from '../common/intl/index';
import { LocalStorageHelper } from '../helpers/localStorageHelper';
// Ensure the real constants module is available even when other test files
// (e.g., install-store.test.ts) mock it with a partial stub.
vi.mock('panel/helpers/constants', async (importOriginal) =>
importOriginal<typeof import('panel/helpers/constants')>(),
);
import {
createSolidDefaultValues,
solidMessageConstructor,
getInitialLanguage,
} from '../common/intl/index';
/** Helper to create real DOM elements for test assertions (SolidJS has no h() export) */
const h = (tag: string, props: Record<string, string> | null, children?: string) => {
@ -205,3 +217,92 @@ describe('intl.getMessage — missing placeholder fallback', () => {
expect(result).toBe('plural_with_value');
});
});
describe('getInitialLanguage', () => {
const LANGUAGE_KEY = 'language';
const setLocation = (href: string) => {
delete (globalThis as any).location;
(globalThis as any).location = new URL(href);
};
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
// Default: a URL with no lang param (so tests without setLocation work)
setLocation('http://127.0.0.1:3001/login.html');
});
afterEach(() => {
localStorage.clear();
delete (globalThis as any).location;
});
const storedLang = () => LocalStorageHelper.getItem<string>(LANGUAGE_KEY);
it('resolves a valid URL query param and persists to localStorage', () => {
setLocation('http://127.0.0.1:3001/login.html?lang=zh-cn');
const result = getInitialLanguage();
expect(result).toBe('zh-cn');
expect(storedLang()).toBe('zh-cn');
});
it('resolves an invalid URL query param to en and persists en', () => {
setLocation('http://127.0.0.1:3001/login.html?lang=garbage');
const result = getInitialLanguage();
expect(result).toBe('en');
expect(storedLang()).toBe('en');
});
it('resolves abbreviated zh to zh-cn', () => {
setLocation('http://127.0.0.1:3001/login.html?lang=zh');
const result = getInitialLanguage();
expect(result).toBe('zh-cn');
expect(storedLang()).toBe('zh-cn');
});
it('falls back to localStorage when no URL param is present', () => {
LocalStorageHelper.setItem(LANGUAGE_KEY, 'de');
const result = getInitialLanguage();
expect(result).toBe('de');
});
it('falls back to navigator.language when no URL param or localStorage', () => {
vi.stubGlobal('navigator', { language: 'fr-FR' });
const result = getInitialLanguage();
expect(result).toBe('fr');
vi.unstubAllGlobals();
});
it('falls back to en when no source provides a valid language', () => {
// No URL param, no localStorage, stub navigator to a nonsense value
vi.stubGlobal('navigator', { language: 'xx-XX' });
const result = getInitialLanguage();
expect(result).toBe('en');
vi.unstubAllGlobals();
});
it('returns en when typeof window is undefined (SSR)', () => {
const originalWindow = globalThis.window;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (globalThis as any).window;
const result = getInitialLanguage();
expect(result).toBe('en');
(globalThis as any).window = originalWindow;
});
});

View file

@ -2,10 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the API client before importing the store.
const mockLogin = vi.fn().mockResolvedValue(undefined);
vi.mock('panel/api/Api', () => ({
apiClient: {
vi.mock('panel/api/generated', () => ({
login: (...args: unknown[]) => mockLogin(...args),
},
}));
// Mock addErrorToast so it does not depend on other stores.
@ -13,8 +11,10 @@ vi.mock('panel/stores/toasts', () => ({
addErrorToast: vi.fn(),
}));
// Mock HTML_PAGES constant.
vi.mock('panel/helpers/constants', () => ({
// Mock HTML_PAGES constant, but pass through all real exports so
// transitive dependencies (e.g., LANGUAGE_QUERY_PARAM in intl) work.
vi.mock('panel/helpers/constants', async (importOriginal) => ({
...(await importOriginal<typeof import('panel/helpers/constants')>()),
HTML_PAGES: { LOGIN: '/login.html', MAIN: '/dashboard.html' },
}));

View file

@ -1,11 +1,9 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { getAdditionalLogs, setFilteredLogs, queryLogsState } from 'panel/stores/queryLogs';
import { apiClient } from 'panel/api/Api';
import { queryLog } from 'panel/api/generated';
vi.mock('panel/api/Api', () => ({
apiClient: {
getQueryLog: vi.fn(),
},
vi.mock('panel/api/generated', () => ({
queryLog: vi.fn(),
}));
vi.mock('panel/stores/toasts', () => ({
@ -19,14 +17,14 @@ describe('queryLogs store', () => {
});
it('getAdditionalLogs appends filtered rows using the oldest cursor and filter', async () => {
(apiClient.getQueryLog as any).mockReset();
(queryLog as any).mockReset();
// Seed state: a full first page (20 rewritten) with more behind it.
const fullPage = Array.from({ length: 20 }, () => ({
reason: 'Rewrite',
question: {},
}));
(apiClient.getQueryLog as any).mockResolvedValueOnce({
(queryLog as any).mockResolvedValueOnce({
data: fullPage,
oldest: 'cur',
});
@ -35,7 +33,7 @@ describe('queryLogs store', () => {
expect(queryLogsState.logs).toHaveLength(20);
// Load more: one extra rewritten entry, then end of log.
(apiClient.getQueryLog as any).mockResolvedValueOnce({
(queryLog as any).mockResolvedValueOnce({
data: [{ reason: 'Rewrite', question: {} }],
oldest: '',
});
@ -43,7 +41,7 @@ describe('queryLogs store', () => {
await getAdditionalLogs();
// The load-more request must carry the cursor and the reason filter.
expect(apiClient.getQueryLog).toHaveBeenLastCalledWith(
expect(queryLog).toHaveBeenLastCalledWith(
expect.objectContaining({
older_than: 'cur',
reason: expect.arrayContaining([
@ -55,7 +53,7 @@ describe('queryLogs store', () => {
}),
);
// Must NOT send the deprecated response_status
const lastCall = (apiClient.getQueryLog as any).mock.calls.at(-1)[0];
const lastCall = (queryLog as any).mock.calls.at(-1)[0];
expect(lastCall).not.toHaveProperty('response_status');
expect(queryLogsState.logs).toHaveLength(21);
@ -64,16 +62,16 @@ describe('queryLogs store', () => {
});
it('setFilteredLogs sends reason strings for blocked status', async () => {
(apiClient.getQueryLog as any).mockReset();
(apiClient.getQueryLog as any).mockResolvedValue({
(queryLog as any).mockReset();
(queryLog as any).mockResolvedValue({
data: [{ reason: 'FilteredBlackList', question: {} }],
oldest: '',
});
await setFilteredLogs({ search: '', status: 'blocked', reason: 'all' });
const lastCall = (apiClient.getQueryLog as any).mock.calls.at(-1)[0];
expect(apiClient.getQueryLog).toHaveBeenLastCalledWith(
const lastCall = (queryLog as any).mock.calls.at(-1)[0];
expect(queryLog).toHaveBeenLastCalledWith(
expect.objectContaining({
reason: expect.arrayContaining([
'FilteredBlackList',
@ -87,7 +85,7 @@ describe('queryLogs store', () => {
});
it('does not mark the log as complete when additional loading stops', async () => {
(apiClient.getQueryLog as any).mockResolvedValue({
(queryLog as any).mockResolvedValue({
data: [],
oldest: 'next-cursor',
});
@ -99,7 +97,7 @@ describe('queryLogs store', () => {
});
it('accumulates pages until oldest is empty (short-polling)', async () => {
(apiClient.getQueryLog as any)
(queryLog as any)
.mockResolvedValueOnce({
data: [{ reason: 'Rewrite' }],
oldest: 'cur',
@ -112,7 +110,37 @@ describe('queryLogs store', () => {
});
await setFilteredLogs({ search: '', status: 'rewritten', reason: 'all' });
expect(apiClient.getQueryLog).toHaveBeenCalledTimes(2);
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

@ -1,16 +1,14 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mocks = vi.hoisted(() => ({
getStats: vi.fn(),
searchClients: vi.fn(),
stats: vi.fn(),
clientsSearch: vi.fn(),
addErrorToast: vi.fn(),
}));
vi.mock('panel/api/Api', () => ({
apiClient: {
getStats: mocks.getStats,
searchClients: mocks.searchClients,
},
vi.mock('panel/api/generated', () => ({
stats: mocks.stats,
clientsSearch: mocks.clientsSearch,
}));
vi.mock('panel/stores/toasts', () => ({
addErrorToast: mocks.addErrorToast,
@ -21,11 +19,11 @@ import { getStats, statsState } from 'panel/stores/stats';
describe('getStats', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.searchClients.mockResolvedValue([]);
mocks.clientsSearch.mockResolvedValue([]);
});
it('enriches top clients and stores normalizedTopClients', async () => {
mocks.getStats.mockResolvedValue({
mocks.stats.mockResolvedValue({
top_clients: [{ '1.2.3.4': 5 }],
avg_processing_time: 0.012,
top_blocked_domains: [],
@ -37,7 +35,7 @@ describe('getStats', () => {
await getStats();
// searchClients was called with discovered client ids
expect(mocks.searchClients).toHaveBeenCalledWith({
expect(mocks.clientsSearch).toHaveBeenCalledWith({
clients: [{ id: '1.2.3.4' }],
});
// normalizedTopClients is populated (configured bucket carries info name)
@ -45,17 +43,17 @@ describe('getStats', () => {
});
it('converts avg_processing_time to milliseconds, falsy passthrough', async () => {
mocks.getStats.mockResolvedValue({ avg_processing_time: 0.012 });
mocks.stats.mockResolvedValue({ avg_processing_time: 0.012 });
await getStats();
expect(statsState.avgProcessingTime).toBe(12);
mocks.getStats.mockResolvedValue({ avg_processing_time: 0 });
mocks.stats.mockResolvedValue({ avg_processing_time: 0 });
await getStats();
expect(statsState.avgProcessingTime).toBe(0); // not NaN
});
it('converts top_upstreams_avg_time entries from seconds to milliseconds', async () => {
mocks.getStats.mockResolvedValue({
mocks.stats.mockResolvedValue({
top_upstreams_avg_time: [{ '1.1.1.1': 0.012 }, { '9.9.9.9': 0.3 }],
top_clients: [],
avg_processing_time: 0,

View file

@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { render } from '@solidjs/testing-library';
import Toast from '../components/Toasts/Toast';
import { Toast } from '../components/Toasts/Toast';
describe('Toast', () => {
it('renders the plain message when no options', () => {

View file

@ -22,11 +22,9 @@ const mocks = vi.hoisted(() => ({
addErrorToast: vi.fn(),
}));
vi.mock('panel/api/Api', () => ({
apiClient: {
setRules: mocks.apiSetRules,
getFilteringStatus: mocks.apiGetFilteringStatus,
},
vi.mock('panel/api/generated', () => ({
filteringSetRules: mocks.apiSetRules,
filteringStatus: mocks.apiGetFilteringStatus,
}));
vi.mock('panel/stores/toasts', () => ({

View file

@ -1,746 +0,0 @@
import type { LocalesType } from 'panel/common/intl';
const BASE_URL = 'control';
import { getPathWithQueryString } from '../helpers/helpers';
import { QUERY_LOGS_PAGE_LIMIT, HTML_PAGES, R_PATH_LAST_PART, THEMES } from '../helpers/constants';
import intl from '../common/intl';
type Theme = (typeof THEMES)[keyof typeof THEMES];
class Api {
baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async makeRequest(path: any, method = 'POST', config: any = {}) {
const pathWithBase = `${this.baseUrl}/${path}`;
const url = config.params
? getPathWithQueryString(pathWithBase, config.params)
: pathWithBase;
const fetchConfig: RequestInit = { method };
const headers: Record<string, string> = {};
if (method !== 'GET' && config.data) {
headers['Content-Type'] = config.headers?.['Content-Type'] || 'application/json';
fetchConfig.body =
typeof config.data === 'string' ? config.data : JSON.stringify(config.data);
}
fetchConfig.headers = headers;
try {
const response = await fetch(url, fetchConfig);
const text = await response.text();
const data = text
? (() => {
try {
return JSON.parse(text);
} catch {
return text;
}
})()
: '';
if (!response.ok) {
const errorPath = url;
const { pathname } = document.location;
const shouldRedirect =
pathname !== HTML_PAGES.LOGIN && pathname !== HTML_PAGES.INSTALL;
if (response.status === 403 && shouldRedirect) {
const loginPageUrl = window.location.href.replace(
R_PATH_LAST_PART,
HTML_PAGES.LOGIN,
);
window.location.replace(loginPageUrl);
return false;
}
throw new Error(`${errorPath} | ${data} | ${response.status}`);
}
return data;
} catch (error) {
const errorPath = url;
if (error instanceof Error && error.message.includes('|')) {
throw error;
}
throw new Error(`${errorPath} | ${error.message || error}`);
}
}
// Global methods
GLOBAL_STATUS = { path: 'status', method: 'GET' };
GLOBAL_TEST_UPSTREAM_DNS = { path: 'test_upstream_dns', method: 'POST' };
GLOBAL_VERSION = { path: 'version.json', method: 'POST' };
GLOBAL_UPDATE = { path: 'update', method: 'POST' };
getGlobalStatus() {
const { path, method } = this.GLOBAL_STATUS;
return this.makeRequest(path, method);
}
testUpstream(servers: any) {
const { path, method } = this.GLOBAL_TEST_UPSTREAM_DNS;
const config = {
data: servers,
};
return this.makeRequest(path, method, config);
}
getGlobalVersion(data: any) {
const { path, method } = this.GLOBAL_VERSION;
const config = {
data,
};
return this.makeRequest(path, method, config);
}
getUpdate() {
const { path, method } = this.GLOBAL_UPDATE;
return this.makeRequest(path, method);
}
// Filtering
FILTERING_STATUS = { path: 'filtering/status', method: 'GET' };
FILTERING_ADD_FILTER = { path: 'filtering/add_url', method: 'POST' };
FILTERING_REMOVE_FILTER = { path: 'filtering/remove_url', method: 'POST' };
FILTERING_SET_RULES = { path: 'filtering/set_rules', method: 'POST' };
FILTERING_REFRESH = { path: 'filtering/refresh', method: 'POST' };
FILTERING_SET_URL = { path: 'filtering/set_url', method: 'POST' };
FILTERING_CONFIG = { path: 'filtering/config', method: 'POST' };
FILTERING_CHECK_HOST = { path: 'filtering/check_host', method: 'GET' };
getFilteringStatus() {
const { path, method } = this.FILTERING_STATUS;
return this.makeRequest(path, method);
}
refreshFilters(config: any) {
const { path, method } = this.FILTERING_REFRESH;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
addFilter(config: any) {
const { path, method } = this.FILTERING_ADD_FILTER;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
removeFilter(config: any) {
const { path, method } = this.FILTERING_REMOVE_FILTER;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
setRules(rules: any) {
const { path, method } = this.FILTERING_SET_RULES;
const parameters = {
data: rules,
};
return this.makeRequest(path, method, parameters);
}
setFiltersConfig(config: any) {
const { path, method } = this.FILTERING_CONFIG;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
setFilterUrl(config: any) {
const { path, method } = this.FILTERING_SET_URL;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
checkHost(params: any) {
const { path, method } = this.FILTERING_CHECK_HOST;
const url = getPathWithQueryString(path, params);
return this.makeRequest(url, method);
}
// Parental
PARENTAL_STATUS = { path: 'parental/status', method: 'GET' };
PARENTAL_ENABLE = { path: 'parental/enable', method: 'POST' };
PARENTAL_DISABLE = { path: 'parental/disable', method: 'POST' };
getParentalStatus() {
const { path, method } = this.PARENTAL_STATUS;
return this.makeRequest(path, method);
}
enableParentalControl() {
const { path, method } = this.PARENTAL_ENABLE;
return this.makeRequest(path, method);
}
disableParentalControl() {
const { path, method } = this.PARENTAL_DISABLE;
return this.makeRequest(path, method);
}
// Safebrowsing
SAFEBROWSING_STATUS = { path: 'safebrowsing/status', method: 'GET' };
SAFEBROWSING_ENABLE = { path: 'safebrowsing/enable', method: 'POST' };
SAFEBROWSING_DISABLE = { path: 'safebrowsing/disable', method: 'POST' };
getSafebrowsingStatus() {
const { path, method } = this.SAFEBROWSING_STATUS;
return this.makeRequest(path, method);
}
enableSafebrowsing() {
const { path, method } = this.SAFEBROWSING_ENABLE;
return this.makeRequest(path, method);
}
disableSafebrowsing() {
const { path, method } = this.SAFEBROWSING_DISABLE;
return this.makeRequest(path, method);
}
// Safesearch
SAFESEARCH_STATUS = { path: 'safesearch/status', method: 'GET' };
SAFESEARCH_UPDATE = { path: 'safesearch/settings', method: 'PUT' };
getSafesearchStatus() {
const { path, method } = this.SAFESEARCH_STATUS;
return this.makeRequest(path, method);
}
/**
* interface SafeSearchConfig {
"enabled": boolean,
"bing": boolean,
"duckduckgo": boolean,
"google": boolean,
"pixabay": boolean,
"yandex": boolean,
"youtube": boolean
* }
* @param {*} data - SafeSearchConfig
* @returns 200 ok
*/
updateSafesearch(data: any) {
const { path, method } = this.SAFESEARCH_UPDATE;
return this.makeRequest(path, method, { data });
}
// enableSafesearch() {
// const { path, method } = this.SAFESEARCH_ENABLE;
// return this.makeRequest(path, method);
// }
// disableSafesearch() {
// const { path, method } = this.SAFESEARCH_DISABLE;
// return this.makeRequest(path, method);
// }
// Language
async changeLanguage(config: { language: LocalesType }) {
const profile = await this.getProfile();
profile.language = config.language;
return this.setProfile(profile);
}
// Theme
async changeTheme(config: { theme: Theme }) {
const profile = await this.getProfile();
profile.theme = config.theme;
return this.setProfile(profile);
}
// DHCP
DHCP_STATUS = { path: 'dhcp/status', method: 'GET' };
DHCP_SET_CONFIG = { path: 'dhcp/set_config', method: 'POST' };
DHCP_FIND_ACTIVE = { path: 'dhcp/find_active_dhcp', method: 'POST' };
DHCP_INTERFACES = { path: 'dhcp/interfaces', method: 'GET' };
DHCP_ADD_STATIC_LEASE = { path: 'dhcp/add_static_lease', method: 'POST' };
DHCP_REMOVE_STATIC_LEASE = { path: 'dhcp/remove_static_lease', method: 'POST' };
DHCP_UPDATE_STATIC_LEASE = { path: 'dhcp/update_static_lease', method: 'POST' };
DHCP_RESET = { path: 'dhcp/reset', method: 'POST' };
DHCP_LEASES_RESET = { path: 'dhcp/reset_leases', method: 'POST' };
getDhcpStatus() {
const { path, method } = this.DHCP_STATUS;
return this.makeRequest(path, method);
}
getDhcpInterfaces() {
const { path, method } = this.DHCP_INTERFACES;
return this.makeRequest(path, method);
}
setDhcpConfig(config: any) {
const { path, method } = this.DHCP_SET_CONFIG;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
findActiveDhcp(req: any) {
const { path, method } = this.DHCP_FIND_ACTIVE;
const parameters = {
data: req,
};
return this.makeRequest(path, method, parameters);
}
addStaticLease(config: any) {
const { path, method } = this.DHCP_ADD_STATIC_LEASE;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
removeStaticLease(config: any) {
const { path, method } = this.DHCP_REMOVE_STATIC_LEASE;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
updateStaticLease(config: any) {
const { path, method } = this.DHCP_UPDATE_STATIC_LEASE;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
resetDhcp() {
const { path, method } = this.DHCP_RESET;
return this.makeRequest(path, method);
}
resetDhcpLeases() {
const { path, method } = this.DHCP_LEASES_RESET;
return this.makeRequest(path, method);
}
// Installation
INSTALL_GET_ADDRESSES = { path: 'install/get_addresses', method: 'GET' };
INSTALL_CONFIGURE = { path: 'install/configure', method: 'POST' };
INSTALL_CHECK_CONFIG = { path: 'install/check_config', method: 'POST' };
getDefaultAddresses() {
const { path, method } = this.INSTALL_GET_ADDRESSES;
return this.makeRequest(path, method);
}
setAllSettings(config: any) {
const { path, method } = this.INSTALL_CONFIGURE;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
checkConfig(config: any) {
const { path, method } = this.INSTALL_CHECK_CONFIG;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
// DNS-over-HTTPS and DNS-over-TLS
TLS_STATUS = { path: 'tls/status', method: 'GET' };
TLS_CONFIG = { path: 'tls/configure', method: 'POST' };
TLS_VALIDATE = { path: 'tls/validate', method: 'POST' };
getTlsStatus() {
const { path, method } = this.TLS_STATUS;
return this.makeRequest(path, method);
}
setTlsConfig(config: any) {
const { path, method } = this.TLS_CONFIG;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
validateTlsConfig(config: any) {
const { path, method } = this.TLS_VALIDATE;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
// Per-client settings
GET_CLIENTS = { path: 'clients', method: 'GET' };
SEARCH_CLIENTS = { path: 'clients/search', method: 'POST' };
ADD_CLIENT = { path: 'clients/add', method: 'POST' };
DELETE_CLIENT = { path: 'clients/delete', method: 'POST' };
UPDATE_CLIENT = { path: 'clients/update', method: 'POST' };
getClients() {
const { path, method } = this.GET_CLIENTS;
return this.makeRequest(path, method);
}
addClient(config: any) {
const { path, method } = this.ADD_CLIENT;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
deleteClient(config: any) {
const { path, method } = this.DELETE_CLIENT;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
updateClient(config: any) {
const { path, method } = this.UPDATE_CLIENT;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
searchClients(config: any) {
const { path, method } = this.SEARCH_CLIENTS;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
// DNS access settings
ACCESS_LIST = { path: 'access/list', method: 'GET' };
ACCESS_SET = { path: 'access/set', method: 'POST' };
getAccessList() {
const { path, method } = this.ACCESS_LIST;
return this.makeRequest(path, method);
}
setAccessList(config: any) {
const { path, method } = this.ACCESS_SET;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
// DNS rewrites
REWRITES_LIST = { path: 'rewrite/list', method: 'GET' };
REWRITE_ADD = { path: 'rewrite/add', method: 'POST' };
REWRITE_UPDATE = { path: 'rewrite/update', method: 'PUT' };
REWRITE_DELETE = { path: 'rewrite/delete', method: 'POST' };
getRewritesList() {
const { path, method } = this.REWRITES_LIST;
return this.makeRequest(path, method);
}
addRewrite(config: any) {
const { path, method } = this.REWRITE_ADD;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
updateRewrite(config: any) {
const { path, method } = this.REWRITE_UPDATE;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
deleteRewrite(config: any) {
const { path, method } = this.REWRITE_DELETE;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
REWRITE_SETTINGS = { path: 'rewrite/settings', method: 'GET' };
REWRITE_SETTINGS_UPDATE = { path: 'rewrite/settings/update', method: 'PUT' };
getRewriteSettings() {
const { path, method } = this.REWRITE_SETTINGS;
return this.makeRequest(path, method);
}
updateRewriteSettings(config: { enabled: boolean }) {
const { path, method } = this.REWRITE_SETTINGS_UPDATE;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
// Blocked services
BLOCKED_SERVICES_GET = { path: 'blocked_services/get', method: 'GET' };
BLOCKED_SERVICES_UPDATE = { path: 'blocked_services/update', method: 'PUT' };
BLOCKED_SERVICES_ALL = { path: 'blocked_services/all', method: 'GET' };
getAllBlockedServices() {
const { path, method } = this.BLOCKED_SERVICES_ALL;
return this.makeRequest(path, method);
}
getBlockedServices() {
const { path, method } = this.BLOCKED_SERVICES_GET;
return this.makeRequest(path, method);
}
updateBlockedServices(config: any) {
const { path, method } = this.BLOCKED_SERVICES_UPDATE;
const parameters = {
data: config,
};
return this.makeRequest(path, method, parameters);
}
// Settings for statistics
GET_STATS = { path: 'stats', method: 'GET' };
GET_STATS_CONFIG = { path: 'stats/config', method: 'GET' };
UPDATE_STATS_CONFIG = { path: 'stats/config/update', method: 'PUT' };
STATS_RESET = { path: 'stats_reset', method: 'POST' };
getStats(recent?: number) {
const { path, method } = this.GET_STATS;
const config = recent ? { params: { recent } } : undefined;
return this.makeRequest(path, method, config);
}
getStatsConfig() {
const { path, method } = this.GET_STATS_CONFIG;
return this.makeRequest(path, method);
}
setStatsConfig(data: any) {
const { path, method } = this.UPDATE_STATS_CONFIG;
const config = {
data,
};
return this.makeRequest(path, method, config);
}
resetStats() {
const { path, method } = this.STATS_RESET;
return this.makeRequest(path, method);
}
// Query log
GET_QUERY_LOG = { path: 'querylog', method: 'GET' };
UPDATE_QUERY_LOG_CONFIG = { path: 'querylog/config/update', method: 'PUT' };
GET_QUERY_LOG_CONFIG = { path: 'querylog/config', method: 'GET' };
QUERY_LOG_CLEAR = { path: 'querylog_clear', method: 'POST' };
getQueryLog(params: any) {
const { path, method } = this.GET_QUERY_LOG;
const url = getPathWithQueryString(path, {
...params,
limit: QUERY_LOGS_PAGE_LIMIT,
});
return this.makeRequest(url, method);
}
getQueryLogConfig() {
const { path, method } = this.GET_QUERY_LOG_CONFIG;
return this.makeRequest(path, method);
}
setQueryLogConfig(data: any) {
const { path, method } = this.UPDATE_QUERY_LOG_CONFIG;
const config = {
data,
};
return this.makeRequest(path, method, config);
}
clearQueryLog() {
const { path, method } = this.QUERY_LOG_CLEAR;
return this.makeRequest(path, method);
}
// Login
LOGIN = { path: 'login', method: 'POST' };
login(data: any) {
const { path, method } = this.LOGIN;
const config = {
data,
};
return this.makeRequest(path, method, config);
}
// Logout
LOGOUT_PATH = 'logout';
getLogoutUrl() {
return `${this.baseUrl}/${this.LOGOUT_PATH}`;
}
// Profile
GET_PROFILE = { path: 'profile', method: 'GET' };
UPDATE_PROFILE = { path: 'profile/update', method: 'PUT' };
getProfile() {
const { path, method } = this.GET_PROFILE;
return this.makeRequest(path, method);
}
setProfile(data: any) {
const theme = data.theme ? data.theme : THEMES.auto;
const defaultLanguage = intl.getUILanguage() ? intl.getUILanguage() : 'en';
const language = data.language ? data.language : defaultLanguage;
const { path, method } = this.UPDATE_PROFILE;
const config = { data: { theme, language } };
return this.makeRequest(path, method, config);
}
// DNS config
GET_DNS_CONFIG = { path: 'dns_info', method: 'GET' };
SET_DNS_CONFIG = { path: 'dns_config', method: 'POST' };
getDnsConfig() {
const { path, method } = this.GET_DNS_CONFIG;
return this.makeRequest(path, method);
}
setDnsConfig(data: any) {
const { path, method } = this.SET_DNS_CONFIG;
const config = {
data,
};
return this.makeRequest(path, method, config);
}
SET_PROTECTION = { path: 'protection', method: 'POST' };
setProtection(data: any) {
const { enabled, duration } = data;
const { path, method } = this.SET_PROTECTION;
return this.makeRequest(path, method, { data: { enabled, duration } });
}
// Cache
CLEAR_CACHE = { path: 'cache_clear', method: 'POST' };
clearCache() {
const { path, method } = this.CLEAR_CACHE;
return this.makeRequest(path, method);
}
}
export const apiClient = new Api(BASE_URL);

View file

@ -0,0 +1,78 @@
import { HTML_PAGES, R_PATH_LAST_PART } from '../helpers/constants';
type CustomFetchOptions = RequestInit & {
skipAuthRedirect?: boolean;
};
/**
* Custom fetch mutator for orval-generated API client.
*
* Custom fetch wrapper providing AdGuard Home-specific behaviors:
* - Sets `Content-Type: application/json` on requests with a body
* - Parses response as JSON (falls back to raw text if JSON.parse fails)
* - On 403 and not on login/install page redirects browser to login
* and returns `false` (does not throw)
* - On 403 while on login/install page throws Error normally
* - Throws Error with format `${path} | ${data} | ${status}` for all
* other non-ok responses
* - On empty response body returns empty string ''
* - On 204 returns empty string ''
*/
export const customFetch = async <T>(url: string, options?: CustomFetchOptions): Promise<T> => {
const { skipAuthRedirect, ...fetchOptions } = options || {};
const fullUrl = url;
const headers: Record<string, string> = {};
// Preserve any headers passed in options
if (fetchOptions.headers) {
const incomingHeaders = fetchOptions.headers as Record<string, string>;
Object.assign(headers, incomingHeaders);
}
try {
const response = await fetch(fullUrl, {
...fetchOptions,
headers,
});
const text = await response.text();
const data: T = text
? (() => {
try {
return JSON.parse(text);
} catch {
return text;
}
})()
: ('' as unknown as T);
if (!response.ok) {
const { pathname } = document.location;
const shouldRedirect = pathname !== HTML_PAGES.LOGIN && pathname !== HTML_PAGES.INSTALL;
if (response.status === 403 && shouldRedirect && !skipAuthRedirect) {
const loginPageUrl = window.location.href.replace(
R_PATH_LAST_PART,
HTML_PAGES.LOGIN,
);
window.location.replace(loginPageUrl);
return false as unknown as T;
}
throw new Error(
`${fullUrl} | ${typeof data === 'string' ? data : JSON.stringify(data)} | ${response.status}`,
);
}
return data;
} catch (error) {
if (error instanceof Error && error.message.includes('|')) {
throw error;
}
throw new Error(`${fullUrl} | ${error instanceof Error ? error.message : String(error)}`);
}
};
export default customFetch;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
/**
* Client and host access list. Each of the lists should contain only unique elements. In addition, allowed and disallowed lists cannot contain the same elements.
*/
export interface AccessList {
/** The allowlist of clients: IP addresses, CIDRs, or ClientIDs. */
allowed_clients?: string[];
/** The blocklist of clients: IP addresses, CIDRs, or ClientIDs. */
disallowed_clients?: string[];
/** The blocklist of hosts. */
blocked_hosts?: string[];
}

View file

@ -0,0 +1,3 @@
import type { AccessList } from './accessList';
export type AccessListResponse = AccessList;

View file

@ -0,0 +1,3 @@
import type { AccessList } from './accessList';
export type AccessSetRequest = AccessList;

View file

@ -0,0 +1,9 @@
/**
* /add_url request data
*/
export interface AddUrlRequest {
name?: string;
/** URL or an absolute path to the file containing filtering rules. */
url?: string;
whitelist?: boolean;
}

View file

@ -0,0 +1,7 @@
/**
* Port information
*/
export interface AddressInfo {
ip: string;
port: number;
}

View file

@ -0,0 +1,11 @@
import type { NetInterfaces } from './netInterfaces';
/**
* AdGuard Home addresses configuration
*/
export interface AddressesInfo {
dns_port: number;
interfaces: NetInterfaces;
version: string;
web_port: number;
}

View file

@ -0,0 +1,12 @@
export interface BlockedService {
/** The SVG icon as a Base64-encoded string to make it easier to embed it into a data URL. */
icon_svg: string;
/** The ID of this service. */
id: string;
/** The human-readable name of this service. */
name: string;
/** The array of the filtering rules. */
rules: string[];
/** The ID of the group, that the service belongs to. */
group_id?: string;
}

View file

@ -0,0 +1,7 @@
import type { BlockedService } from './blockedService';
import type { ServiceGroup } from './serviceGroup';
export interface BlockedServicesAll {
blocked_services: BlockedService[];
groups: ServiceGroup[];
}

View file

@ -0,0 +1 @@
export type BlockedServicesArray = string[];

View file

@ -0,0 +1,7 @@
import type { Schedule } from './schedule';
export interface BlockedServicesSchedule {
schedule?: Schedule;
/** The names of the blocked services. */
ids?: string[];
}

View file

@ -0,0 +1,12 @@
import type { CheckConfigRequestInfo } from './checkConfigRequestInfo';
import type { Lang } from './lang';
/**
* Configuration to be checked
*/
export interface CheckConfigRequest {
dns?: CheckConfigRequestInfo;
language?: Lang;
set_static_ip?: boolean;
web?: CheckConfigRequestInfo;
}

View file

@ -0,0 +1,5 @@
export interface CheckConfigRequestInfo {
ip?: string;
port?: number;
autofix?: boolean;
}

View file

@ -0,0 +1,9 @@
import type { CheckConfigResponseInfo } from './checkConfigResponseInfo';
import type { CheckConfigStaticIpInfo } from './checkConfigStaticIpInfo';
export interface CheckConfigResponse {
dns: CheckConfigResponseInfo;
language: CheckConfigResponseInfo;
static_ip: CheckConfigStaticIpInfo;
web: CheckConfigResponseInfo;
}

View file

@ -0,0 +1,4 @@
export interface CheckConfigResponseInfo {
status: string;
can_autofix: boolean;
}

View file

@ -0,0 +1,9 @@
import type { CheckConfigStaticIpInfoStatic } from './checkConfigStaticIpInfoStatic';
export interface CheckConfigStaticIpInfo {
static?: CheckConfigStaticIpInfoStatic;
/** Current dynamic IP address. Set if static=no */
ip?: string;
/** Error text. Set if static=error */
error?: string;
}

View file

@ -0,0 +1,4 @@
/**
* Can be: yes, no, error
*/
export type CheckConfigStaticIpInfoStatic = 'yes' | 'no' | 'error';

View file

@ -0,0 +1,63 @@
import type { SafeSearchConfig } from './safeSearchConfig';
import type { Schedule } from './schedule';
/**
* Client information.
*/
export interface Client {
/** Name */
name?: string;
/** IP, CIDR, MAC, or ClientID. */
ids?: string[];
use_global_settings?: boolean;
filtering_enabled?: boolean;
parental_enabled?: boolean;
safebrowsing_enabled?: boolean;
/** @deprecated */
safesearch_enabled?: boolean;
safe_search?: SafeSearchConfig;
use_global_blocked_services?: boolean;
blocked_services_schedule?: Schedule;
blocked_services?: string[];
upstreams?: string[];
tags?: string[];
/**
* NOTE: If `ignore_querylog` is not set in HTTP API `GET /clients/add`
* request then default value (false) will be used.
*
* If `ignore_querylog` is not set in HTTP API `GET /clients/update`
* request then the existing value will not be changed.
*
* This behaviour can be changed in the future versions.
*/
ignore_querylog?: boolean;
/**
* NOTE: If `ignore_statistics` is not set in HTTP API `GET
* /clients/add` request then default value (false) will be used.
*
* If `ignore_statistics` is not set in HTTP API `GET /clients/update`
* request then the existing value will not be changed.
*
* This behaviour can be changed in the future versions.
*/
ignore_statistics?: boolean;
/**
* NOTE: If `upstreams_cache_enabled` is not set in HTTP API
* `GET /clients/add` request then default value (false) will be used.
*
* If `upstreams_cache_enabled` is not set in HTTP API
* `GET /clients/update` request then the existing value will not be
* changed.
*
* This behaviour can be changed in the future versions.
*/
upstreams_cache_enabled?: boolean;
/**
* NOTE: If `upstreams_cache_enabled` is not set in HTTP API
* `GET /clients/update` request then the existing value will not be
* changed.
*
* This behaviour can be changed in the future versions.
*/
upstreams_cache_size?: number;
}

View file

@ -0,0 +1,14 @@
import type { WhoisInfo } from './whoisInfo';
/**
* Auto-Client information
*/
export interface ClientAuto {
/** IP address */
ip?: string;
/** Name */
name?: string;
/** The source of this information */
source?: string;
whois_info?: WhoisInfo;
}

View file

@ -0,0 +1,6 @@
/**
* Client delete request
*/
export interface ClientDelete {
name?: string;
}

View file

@ -0,0 +1,29 @@
import type { SafeSearchConfig } from './safeSearchConfig';
import type { WhoisInfo } from './whoisInfo';
/**
* Client information.
*/
export interface ClientFindSubEntry {
/** Name */
name?: string;
/** IP, CIDR, MAC, or ClientID. */
ids?: string[];
use_global_settings?: boolean;
filtering_enabled?: boolean;
parental_enabled?: boolean;
safebrowsing_enabled?: boolean;
/** @deprecated */
safesearch_enabled?: boolean;
safe_search?: SafeSearchConfig;
use_global_blocked_services?: boolean;
blocked_services?: string[];
upstreams?: string[];
whois_info?: WhoisInfo;
/** Whether the client's IP is blocked or not. */
disallowed?: boolean;
/** The rule due to which the client is disallowed. If disallowed is set to true, and this string is empty, then the client IP is disallowed by the "allowed IP list", that is it is not included in the allowed list. */
disallowed_rule?: string;
ignore_querylog?: boolean;
ignore_statistics?: boolean;
}

View file

@ -0,0 +1,9 @@
import type { Client } from './client';
/**
* Client update request
*/
export interface ClientUpdate {
name?: string;
data?: Client;
}

View file

@ -0,0 +1,8 @@
import type { ClientsArray } from './clientsArray';
import type { ClientsAutoArray } from './clientsAutoArray';
export interface Clients {
clients?: ClientsArray;
auto_clients?: ClientsAutoArray;
supported_tags?: string[];
}

View file

@ -0,0 +1,6 @@
import type { Client } from './client';
/**
* Clients array
*/
export type ClientsArray = Client[];

View file

@ -0,0 +1,6 @@
import type { ClientAuto } from './clientAuto';
/**
* Auto-Clients array
*/
export type ClientsAutoArray = ClientAuto[];

View file

@ -0,0 +1,5 @@
import type { ClientFindSubEntry } from './clientFindSubEntry';
export interface ClientsFindEntry {
[key: string]: ClientFindSubEntry;
}

View file

@ -0,0 +1,7 @@
export type ClientsFindParams = {
/**
* Filter by IP address or ClientIDs. Parameters with names `ip1`, `ip2`, and so on are also accepted and interpreted as "ip0 OR ip1 OR ip2".
* TODO(a.garipov): Replace with a better query API.
*/
ip0?: string;
};

View file

@ -0,0 +1,6 @@
import type { ClientsFindEntry } from './clientsFindEntry';
/**
* Client search results.
*/
export type ClientsFindResponse = ClientsFindEntry[];

View file

@ -0,0 +1,8 @@
import type { ClientsSearchRequestItem } from './clientsSearchRequestItem';
/**
* Client search request
*/
export interface ClientsSearchRequest {
clients?: ClientsSearchRequestItem[];
}

View file

@ -0,0 +1,4 @@
export interface ClientsSearchRequestItem {
/** Client IP address, CIDR, MAC address, or ClientID */
id?: string;
}

View file

@ -0,0 +1,15 @@
/**
* DHCP network interface info
*/
export interface DHCPNetInterface {
/** Flags could be any combination of the following values, divided by the "|" character: "up", "broadcast", "loopback", "pointtopoint" and "multicast". */
flags: string;
/** The IP address of the gateway. */
gateway_ip: string;
hardware_address: string;
/** The addresses of the interface of v4 family. */
ipv4_addresses: string[];
/** The addresses of the interface of v6 family. */
ipv6_addresses: string[];
name: string;
}

View file

@ -0,0 +1,8 @@
import type { DHCPNetInterface } from './dHCPNetInterface';
/**
* DHCP network interfaces dictionary, keys are interface names.
*/
export interface DHCPNetInterfaces {
[key: string]: DHCPNetInterface;
}

View file

@ -0,0 +1,69 @@
import type { DNSConfigBlockingMode } from './dNSConfigBlockingMode';
import type { DNSConfigUpstreamMode } from './dNSConfigUpstreamMode';
/**
* DNS server configuration
*/
export interface DNSConfig {
/** Bootstrap servers, port is optional after colon. Empty value will reset it to default values. Comments are allowed, a comment line starts with a `#` symbol. */
bootstrap_dns?: string[];
/** Upstream servers, port is optional after colon. Empty value will reset it to default values. Comments are allowed, a comment line starts with a `#` symbol. */
upstream_dns?: string[];
/** List of fallback DNS servers used when upstream DNS servers are not responding. Empty value will clear the list. Comments are allowed, a comment line starts with a `#` symbol. */
fallback_dns?: string[];
upstream_dns_file?: string;
protection_enabled?: boolean;
ratelimit?: number;
/**
* Length of the subnet mask for IPv4 addresses.
* @minimum 0
* @maximum 32
*/
ratelimit_subnet_len_ipv4?: number;
/**
* Length of the subnet mask for IPv6 addresses.
* @minimum 0
* @maximum 128
*/
ratelimit_subnet_len_ipv6?: number;
/** List of IP addresses excluded from rate limiting. */
ratelimit_whitelist?: string[];
blocking_mode?: DNSConfigBlockingMode;
blocking_ipv4?: string;
blocking_ipv6?: string;
/**
* TTL for blocked responses.
* @minimum 0
*/
blocked_response_ttl?: number;
/** Protection is pause until this time. Nullable. */
protection_disabled_until?: string;
edns_cs_enabled?: boolean;
edns_cs_use_custom?: boolean;
edns_cs_custom_ip?: string;
disable_ipv6?: boolean;
dnssec_enabled?: boolean;
cache_size?: number;
cache_ttl_min?: number;
cache_ttl_max?: number;
/**
* Enables or disables the DNS response cache.
*
* If `cache_enabled` is `true`, the companion field `cache_size` must
* be present and greater than 0, or the `dns.cache_size` setting in
* the configuration file must already be greater than 0.
*/
cache_enabled?: boolean;
cache_optimistic?: boolean;
/** Upstream modes enumeration. The empty string value is deprecated; use `load_balance` instead. */
upstream_mode?: DNSConfigUpstreamMode;
use_private_ptr_resolvers?: boolean;
resolve_clients?: boolean;
/** Upstream servers, port is optional after colon. Empty value will reset it to default values. Comments are allowed, a comment line starts with a `#` symbol. */
local_ptr_upstreams?: string[];
/**
* The number of seconds to wait for a response from the upstream server
* @minimum 1
*/
upstream_timeout?: number;
}

View file

@ -0,0 +1 @@
export type DNSConfigBlockingMode = 'default' | 'refused' | 'nxdomain' | 'null_ip' | 'custom_ip';

View file

@ -0,0 +1,4 @@
/**
* Upstream modes enumeration. The empty string value is deprecated; use `load_balance` instead.
*/
export type DNSConfigUpstreamMode = '' | 'fastest_addr' | 'load_balance' | 'parallel';

View file

@ -0,0 +1,17 @@
/**
* The single interval within a day. It begins at the `start` and ends before the `end`.
*/
export interface DayRange {
/**
* The number of milliseconds elapsed from the start of a day. It must be less than `end` and is expected to be rounded to minutes. So the maximum value is `86340000` (23 hours and 59 minutes).
* @minimum 0
* @maximum 86340000
*/
start?: number;
/**
* The number of milliseconds elapsed from the start of a day. It is expected to be rounded to minutes. The maximum value is `86400000` (24 hours).
* @minimum 0
* @maximum 86400000
*/
end?: number;
}

View file

@ -0,0 +1,9 @@
import type { DhcpConfigV4 } from './dhcpConfigV4';
import type { DhcpConfigV6 } from './dhcpConfigV6';
export interface DhcpConfig {
enabled?: boolean;
interface_name?: string;
v4?: DhcpConfigV4;
v6?: DhcpConfigV6;
}

View file

@ -0,0 +1,7 @@
export interface DhcpConfigV4 {
gateway_ip: string;
subnet_mask: string;
range_start: string;
range_end: string;
lease_duration: number;
}

View file

@ -0,0 +1,4 @@
export interface DhcpConfigV6 {
range_start?: string;
lease_duration?: number;
}

View file

@ -0,0 +1,7 @@
/**
* Request for checking for other DHCP servers in the network.
*/
export interface DhcpFindActiveReq {
/** The name of the network interface */
interface?: string;
}

View file

@ -0,0 +1,9 @@
/**
* DHCP lease information
*/
export interface DhcpLease {
mac: string;
ip: string;
hostname: string;
expires: string;
}

View file

@ -0,0 +1,10 @@
import type { DhcpSearchV4 } from './dhcpSearchV4';
import type { DhcpSearchV6 } from './dhcpSearchV6';
/**
* Information about a DHCP server discovered in the current network.
*/
export interface DhcpSearchResult {
v4?: DhcpSearchV4;
v6?: DhcpSearchV6;
}

View file

@ -0,0 +1,8 @@
import type { DhcpSearchResultOtherServerFound } from './dhcpSearchResultOtherServerFound';
export interface DhcpSearchResultOtherServer {
/** The result of searching the other DHCP server. */
found?: DhcpSearchResultOtherServerFound;
/** Set if found=error */
error?: string;
}

View file

@ -0,0 +1,4 @@
/**
* The result of searching the other DHCP server.
*/
export type DhcpSearchResultOtherServerFound = 'yes' | 'no' | 'error';

View file

@ -0,0 +1,8 @@
import type { DhcpSearchResultStaticIPStatic } from './dhcpSearchResultStaticIPStatic';
export interface DhcpSearchResultStaticIP {
/** The result of determining static IP address. */
static?: DhcpSearchResultStaticIPStatic;
/** Set if static=no */
ip?: string;
}

View file

@ -0,0 +1,4 @@
/**
* The result of determining static IP address.
*/
export type DhcpSearchResultStaticIPStatic = 'yes' | 'no' | 'error';

View file

@ -0,0 +1,7 @@
import type { DhcpSearchResultOtherServer } from './dhcpSearchResultOtherServer';
import type { DhcpSearchResultStaticIP } from './dhcpSearchResultStaticIP';
export interface DhcpSearchV4 {
other_server?: DhcpSearchResultOtherServer;
static_ip?: DhcpSearchResultStaticIP;
}

View file

@ -0,0 +1,5 @@
import type { DhcpSearchResultOtherServer } from './dhcpSearchResultOtherServer';
export interface DhcpSearchV6 {
other_server?: DhcpSearchResultOtherServer;
}

View file

@ -0,0 +1,8 @@
/**
* DHCP static lease information
*/
export interface DhcpStaticLease {
mac: string;
ip: string;
hostname: string;
}

View file

@ -0,0 +1,3 @@
import type { DhcpStaticLease } from './dhcpStaticLease';
export type DhcpStaticLeaseBody = DhcpStaticLease;

View file

@ -0,0 +1,16 @@
import type { DhcpConfigV4 } from './dhcpConfigV4';
import type { DhcpConfigV6 } from './dhcpConfigV6';
import type { DhcpLease } from './dhcpLease';
import type { DhcpStaticLease } from './dhcpStaticLease';
/**
* Built-in DHCP server configuration and status
*/
export interface DhcpStatus {
enabled?: boolean;
interface_name?: string;
v4?: DhcpConfigV4;
v6?: DhcpConfigV6;
leases: DhcpLease[];
static_leases?: DhcpStaticLease[];
}

View file

@ -0,0 +1,8 @@
/**
* DNS answer section
*/
export interface DnsAnswer {
ttl?: number;
type?: string;
value?: string;
}

View file

@ -0,0 +1,5 @@
import type { DNSConfig } from './dNSConfig';
export type DnsInfo200 = DNSConfig & {
default_local_ptr_upstreams?: string[];
};

View file

@ -0,0 +1,9 @@
/**
* DNS question section
*/
export interface DnsQuestion {
class?: string;
name?: string;
unicode_name?: string;
type?: string;
}

View file

@ -0,0 +1,7 @@
/**
* A generic JSON error response.
*/
export interface Error {
/** The error message, an opaque string. */
message?: string;
}

View file

@ -0,0 +1,11 @@
/**
* Filter subscription info
*/
export interface Filter {
enabled: boolean;
id: number;
last_updated?: string;
name: string;
rules_count: number;
url: string;
}

View file

@ -0,0 +1,29 @@
import type { FilteringReason } from './filteringReason';
import type { ResultRule } from './resultRule';
/**
* Check Host Result
*/
export interface FilterCheckHostResponse {
reason?: FilteringReason;
/**
* In case if there's a rule applied to this DNS request, this is ID of the filter list that the rule belongs to.
* Deprecated: use `rules[*].filter_list_id` instead.
* @deprecated
*/
filter_id?: number;
/**
* Filtering rule applied to the request (if any).
* Deprecated: use `rules[*].text` instead.
* @deprecated
*/
rule?: string;
/** Applied rules. */
rules?: ResultRule[];
/** Set if reason=FilteredBlockedService */
service_name?: string;
/** Set if reason=Rewrite */
cname?: string;
/** Set if reason=Rewrite */
ip_addrs?: string[];
}

View file

@ -0,0 +1,7 @@
/**
* Filtering settings
*/
export interface FilterConfig {
enabled?: boolean;
interval?: number;
}

View file

@ -0,0 +1,6 @@
/**
* Refresh Filters request data
*/
export interface FilterRefreshRequest {
whitelist?: boolean;
}

Some files were not shown because too many files have changed in this diff Show more