mirror of
https://github.com/AdguardTeam/AdGuardHome.git
synced 2026-10-12 02:01:26 +00:00
Pull request 2680: ADG-12497 add client v3
Squashed commit of the following: commit05e167266dAuthor: Ildar Kamalov <[email protected]> Date: Fri Jun 26 15:51:03 2026 +0300 lint fix commit630a314c9bAuthor: Ildar Kamalov <[email protected]> Date: Fri Jun 26 15:50:24 2026 +0300 fix link color commitb41df48adbAuthor: Ildar Kamalov <[email protected]> Date: Fri Jun 26 15:00:34 2026 +0300 fix hostname validation commit2b6ae03d21Author: Ildar Kamalov <[email protected]> Date: Thu Jun 25 18:53:17 2026 +0300 fix query log filters commit3a890b66acAuthor: Ildar Kamalov <[email protected]> Date: Thu Jun 25 18:22:57 2026 +0300 revert querylog backend commit86e18cfb7aMerge:20158785fa56d7ecefAuthor: Ildar Kamalov <[email protected]> Date: Thu Jun 25 17:51:46 2026 +0300 Merge branch 'ADG-10291' into ADG-10291-solidjs-client_v3 commit20158785f0Author: Ildar Kamalov <[email protected]> Date: Thu Jun 25 16:48:10 2026 +0300 fix validation issues commit0e677a7349Author: Ildar Kamalov <[email protected]> Date: Thu Jun 25 09:39:53 2026 +0300 fix clients commit27a88f16c8Author: Ildar Kamalov <[email protected]> Date: Wed Jun 24 18:40:50 2026 +0300 fix static leases commit89661fcc16Author: Ildar Kamalov <[email protected]> Date: Wed Jun 24 17:50:31 2026 +0300 fix encryption and dns settings commit37dd39ad82Author: Ildar Kamalov <[email protected]> Date: Wed Jun 24 16:52:44 2026 +0300 supress intl runtime error commit4cc3694cd6Author: Ildar Kamalov <[email protected]> Date: Wed Jun 24 15:05:05 2026 +0300 fix dialog alignment commit3f499bf540Author: Ildar Kamalov <[email protected]> Date: Wed Jun 24 13:21:35 2026 +0300 fix tests commitda5d2db92cAuthor: Ildar Kamalov <[email protected]> Date: Wed Jun 24 13:09:57 2026 +0300 fix query log search issue commit29f5fcb62cAuthor: Ildar Kamalov <[email protected]> Date: Wed Jun 24 11:28:10 2026 +0300 fix undo actions commitce39e94aaaAuthor: Ildar Kamalov <[email protected]> Date: Wed Jun 24 11:21:05 2026 +0300 fix form issues commitfa07652b46Author: Ildar Kamalov <[email protected]> Date: Tue Jun 23 20:57:04 2026 +0300 fix pagination commit7783bd5302Author: Ildar Kamalov <[email protected]> Date: Tue Jun 23 20:37:09 2026 +0300 fix commit6e73d9040cAuthor: Ildar Kamalov <[email protected]> Date: Tue Jun 23 20:36:07 2026 +0300 fix commit411989681fAuthor: Ildar Kamalov <[email protected]> Date: Tue Jun 23 19:43:08 2026 +0300 fix blocklists commit0df56b28eaAuthor: Ildar Kamalov <[email protected]> Date: Tue Jun 23 18:54:17 2026 +0300 fix select position commit26938efa43Author: Ildar Kamalov <[email protected]> Date: Tue Jun 23 18:49:29 2026 +0300 fix commit6f88dd5731Author: Ildar Kamalov <[email protected]> Date: Tue Jun 23 18:36:28 2026 +0300 fix identifiers commit5ddd2e9b72Author: Ildar Kamalov <[email protected]> Date: Tue Jun 23 17:48:08 2026 +0300 fix dialog width commit6b01a5c6e0Author: Ildar Kamalov <[email protected]> Date: Tue Jun 23 17:31:47 2026 +0300 fix alias ... and 21 more commits
This commit is contained in:
committed by
Ildar Kamalov
parent
a56d7ecefb
commit
dd149826f3
@@ -19,8 +19,8 @@ GO.MACRO = $${GO:-go}
|
||||
VERBOSE.MACRO = $${VERBOSE:-0}
|
||||
|
||||
CHANNEL = development
|
||||
# TODO(ik): Update CLIENT_DIR to client_v2 for new frontend migration
|
||||
CLIENT_DIR = client_v2
|
||||
# TODO(ik): Update CLIENT_DIR to client_v3 for new frontend migration
|
||||
CLIENT_DIR = client_v3
|
||||
DEPLOY_SCRIPT_PATH = not/a/real/path
|
||||
DIST_DIR = dist
|
||||
GOAMD64 = v1
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 120
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,70 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
plugins: ['prettier', 'solid', 'import'],
|
||||
extends: [
|
||||
'prettier',
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:solid/typescript',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
env: {
|
||||
node: true,
|
||||
browser: true,
|
||||
commonjs: true,
|
||||
},
|
||||
settings: {
|
||||
'import/core-modules': ['Twosky'],
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
alwaysTryTypes: true,
|
||||
project: [path.resolve(__dirname, 'tsconfig.json')],
|
||||
},
|
||||
node: {
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'import/extensions': 'off',
|
||||
'class-methods-use-this': 'off',
|
||||
'no-shadow': 'off',
|
||||
camelcase: 'off',
|
||||
'no-console': [
|
||||
'warn',
|
||||
{
|
||||
allow: ['warn', 'error'],
|
||||
},
|
||||
],
|
||||
'import/no-extraneous-dependencies': [
|
||||
'error',
|
||||
{
|
||||
devDependencies: true,
|
||||
},
|
||||
],
|
||||
'import/prefer-default-export': 'off',
|
||||
'no-alert': 'off',
|
||||
'arrow-body-style': 'off',
|
||||
'max-len': [
|
||||
'error',
|
||||
120,
|
||||
2,
|
||||
{
|
||||
ignoreUrls: true,
|
||||
ignoreComments: false,
|
||||
ignoreRegExpLiterals: true,
|
||||
ignoreStrings: true,
|
||||
ignoreTemplateLiterals: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
*.ts text eol=lf
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"tabWidth": 4,
|
||||
"semi": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.json",
|
||||
"options": {
|
||||
"tabWidth": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
# AGENTS.md — AdGuard Home client_v3 (SolidJS Frontend)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [AGENTS.md — AdGuard Home client_v3 (SolidJS Frontend)](#agentsmd--adguard-home-client_v3-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)
|
||||
|
||||
# Project Overview
|
||||
|
||||
`client_v3` is the next-generation web UI for **AdGuard Home**, written in
|
||||
**SolidJS** and TypeScript. It is a multi-entry single-page application that
|
||||
runs in the browser and communicates with the AdGuard Home backend over its
|
||||
HTTP `/control` JSON API.
|
||||
|
||||
The app ships four separate HTML entry points that share the same codebase,
|
||||
styles, and theme system:
|
||||
|
||||
- `main` — the dashboard / control panel (HashRouter SPA)
|
||||
- `install` — the first-run setup wizard
|
||||
- `login` — the sign-in page
|
||||
- `forgot_password` — the password-reset flow
|
||||
|
||||
It replaces the legacy React-based client (`client/`) and is built with
|
||||
Webpack, styled with PostCSS + CSS Modules, and tested with Vitest (unit) and
|
||||
Playwright (e2e).
|
||||
|
||||
# Technical Context
|
||||
|
||||
- **Language/Version**: TypeScript 5.4+ (`strict: false`, `noImplicitAny: true`)
|
||||
- **UI Framework**: SolidJS 1.9 (`solid-js`, `@solidjs/router` 0.15 with
|
||||
`HashRouter`)
|
||||
- **UI Primitives**: `@ark-ui/solid` (headless components),
|
||||
`@modular-forms/solid` (form state)
|
||||
- **Build Tooling**: Webpack 5 (`webpack.common.js` / `webpack.dev.js` /
|
||||
`webpack.prod.js`), Babel (`babel-preset-solid`), PostCSS
|
||||
- **Styling**: PostCSS (`postcss-import`, `postcss-nested`, `autoprefixer`) +
|
||||
CSS Modules (`.module.pcss`); design tokens via CSS custom properties
|
||||
- **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/`
|
||||
- **Testing**: Vitest 4 + `@solidjs/testing-library` (unit);
|
||||
Playwright 1.56 (e2e against a real backend)
|
||||
- **Linting/Formatting**: ESLint 8 (`@typescript-eslint`, `eslint-plugin-solid`,
|
||||
`eslint-plugin-prettier`), Prettier 3
|
||||
- **Storage**: Browser `localStorage` (language, theme); no database
|
||||
- **Target Platform**: Modern evergreen browsers (see `browserslist` in
|
||||
`package.json`); output is static assets served by the Go backend
|
||||
- **Project Type**: Website (single, browser-resident SPA)
|
||||
- **Performance Goals**: N/A (not formally documented)
|
||||
- **Constraints**: Output is embedded into the Go binary's `build/static/`;
|
||||
the dev server proxies to a running AdGuard Home instance
|
||||
- **Scale/Scope**: Single-user admin UI (one operator per AdGuard Home
|
||||
installation)
|
||||
|
||||
# Project Structure
|
||||
|
||||
```text
|
||||
client_v3/
|
||||
├── src/
|
||||
│ ├── 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
|
||||
│ ├── common/
|
||||
│ │ ├── controls/ # Input primitives (Checkbox, Input, Select, Switch, …)
|
||||
│ │ ├── ui/ # Higher-level UI (Button, Dialog, Table, Tabs, Sidebar, …)
|
||||
│ │ ├── intl/ # i18n setup (@adguard/translate)
|
||||
│ │ └── styles/ # Theme tokens (vars.css, colors/light|dark|adg.css)
|
||||
│ ├── components/ # Feature/page components (Dashboard, Clients, QueryLog, …)
|
||||
│ │ ├── App/ # Root component — HashRouter + layout + <Route>s
|
||||
│ │ └── Routes/Paths.ts # Route path constants
|
||||
│ ├── helpers/ # Pure utilities, validators, theme helpers
|
||||
│ ├── hooks/ # Shared SolidJS hooks
|
||||
│ ├── lib/ # Theme aggregation, misc utils
|
||||
│ ├── store/types.ts # Legacy type re-exports (post-Redux)
|
||||
│ ├── stores/ # SolidJS createStore modules — one per domain
|
||||
│ ├── types/ # css-modules.d.ts ambient declarations
|
||||
│ ├── login/ # Standalone login entry
|
||||
│ ├── install/ # Standalone install-wizard entry
|
||||
│ ├── forgot_password/ # Standalone password-reset entry
|
||||
│ ├── __locales/ # Translation JSON (en, ru, de, …)
|
||||
│ └── __tests__/ # Vitest unit/integration tests
|
||||
├── tests/e2e/ # Playwright e2e specs
|
||||
├── scripts/ # check-translations.js, translation-audit.js
|
||||
├── webpack.common.js # 4 entry points, loaders, path aliases
|
||||
├── webpack.dev.js # Dev server + backend proxy
|
||||
├── webpack.prod.js # Production build
|
||||
├── tsconfig.json # Path aliases: panel/* → src/*, Twosky → ../.twosky.json
|
||||
├── vitest.config.ts # jsdom + vite-plugin-solid
|
||||
├── playwright.config.ts # e2e config (base URL :3000)
|
||||
├── postcss.config.js # postcss-import + nested + autoprefixer
|
||||
├── package.json # Scripts + dependencies
|
||||
└── constants.js # BUILD_ENVS, BASE_URL = 'control'
|
||||
```
|
||||
|
||||
# Build And Test Commands
|
||||
|
||||
All commands are run from the `client_v3/` directory.
|
||||
|
||||
| Task | Command |
|
||||
| ------------------------------------ | ------------------------------ |
|
||||
| Dev server (proxies to backend) | `npm run dev` |
|
||||
| Production build | `npm run build-prod` |
|
||||
| Dev build (no server) | `npm run build-dev` |
|
||||
| Watch build | `npm run watch` |
|
||||
| Type check | `npm run typecheck` |
|
||||
| Lint | `npm run lint` |
|
||||
| Lint + format fix | `npm run lint:fix` |
|
||||
| Unit tests (single run) | `npm run test` |
|
||||
| Unit tests (watch) | `npm run test:watch` |
|
||||
| E2e tests | `npm run test:e2e` |
|
||||
| E2e interactive UI | `npm run test:e2e:interactive` |
|
||||
| Translation check | `npm run translations:check` |
|
||||
| Full check (lint + typecheck + test) | `npm run check` |
|
||||
|
||||
# Contribution Instructions
|
||||
|
||||
- You MUST verify your work with the linter, formatter, and type checker.
|
||||
|
||||
Use the following commands:
|
||||
- `npm run typecheck` to check for type errors
|
||||
- `npm run lint` to run the linter
|
||||
- `npm run lint:fix` to fix linting and formatting issues automatically
|
||||
|
||||
- You MUST update the unit tests for changed code. New stores, helpers, and
|
||||
components should have corresponding tests under `src/__tests__/`.
|
||||
|
||||
- You MUST run tests with `npm run test` to verify that your changes do not
|
||||
break existing functionality. Use `npm run check` for the full gate
|
||||
(lint + typecheck + test).
|
||||
|
||||
- When making changes to the project structure, ensure the Project Structure
|
||||
section in `AGENTS.md` is updated and remains valid.
|
||||
|
||||
- If the prompt essentially asks you to refactor or improve existing code,
|
||||
check if you can phrase it as a code guideline. If it is possible, add it to
|
||||
the relevant Code Guidelines section in `AGENTS.md`.
|
||||
|
||||
- After completing the task you MUST verify that the code you have written
|
||||
follows the Code Guidelines in this file.
|
||||
|
||||
# Code Guidelines
|
||||
|
||||
## System Design
|
||||
|
||||
`client_v3` is a browser-resident SPA — it runs entirely client-side and has
|
||||
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.
|
||||
- **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.
|
||||
- **Disposability.** The app boots in the browser on demand; clean up
|
||||
subscriptions and timers in `onCleanup` to avoid leaks across route changes.
|
||||
- **HashRouter routing.** The main app uses `HashRouter`; route paths are
|
||||
defined as constants in `src/components/Routes/Paths.ts`. The three
|
||||
standalone pages (`install`, `login`, `forgot_password`) are separate entry
|
||||
points, not routes inside the main app.
|
||||
- **Theme via attributes, not JS state.** Theming is driven by the
|
||||
`data-theme` attribute on `<html>` and CSS custom properties; switch themes
|
||||
through `setUITheme` in `src/helpers/helpers.tsx`, not by toggling
|
||||
component-level classes.
|
||||
- **Static asset output.** The production build emits static files into
|
||||
`../build/static/` which the Go binary embeds. Do not introduce runtime
|
||||
server requirements (Node, databases, etc.) — the built output must be
|
||||
plain static files.
|
||||
|
||||
## Architecture
|
||||
|
||||
Universal design principles the codebase follows:
|
||||
|
||||
- **Separation of Concerns** — UI components, state stores, API access, and
|
||||
pure helpers live in distinct directories.
|
||||
- **Single Responsibility Principle** — each store module owns one domain;
|
||||
each component one page or control.
|
||||
- **Dependency Direction** — dependencies point downward: components → stores
|
||||
→ 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.
|
||||
- **Minimize Coupling, Maximize Cohesion** — stores are self-contained and
|
||||
imported directly (no Context providers); components depend on narrow store
|
||||
exports.
|
||||
- **Make Invalid States Impossible** — domain types in `initialState.ts`
|
||||
constrain shapes; form builders (e.g. `buildClientConfig`) normalize/sanitize
|
||||
before submission.
|
||||
- **Observability Built-in** — errors surface as user-facing toasts via the
|
||||
`toasts` store; `console.warn`/`console.error` are allowed but
|
||||
`console.log` is lint-disallowed.
|
||||
- **Keep It Boring** — prefer SolidJS primitives (`createSignal`,
|
||||
`createMemo`, `createStore`) over clever abstractions.
|
||||
|
||||
The easiest way to achieve these principles is **layered architecture**.
|
||||
This project's layers, from top to bottom:
|
||||
|
||||
| Layer | Responsibility | Examples |
|
||||
| ---------- | ---------------------------------- | ------------------------------------------------------ |
|
||||
| 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` |
|
||||
| Helpers | Pure utilities, validators | `src/helpers/`, `src/lib/` |
|
||||
|
||||
```text
|
||||
Components (pages, controls)
|
||||
↓
|
||||
Stores (domain state + actions)
|
||||
↓
|
||||
API (apiClient → fetch /control)
|
||||
↓
|
||||
AdGuard Home backend (Go)
|
||||
```
|
||||
|
||||
Components may call stores and helpers. Stores may call the API. The API layer
|
||||
must not depend on stores or components. Helpers are pure and dependency-free.
|
||||
|
||||
**Known exclusions** (to be fixed):
|
||||
|
||||
- `src/store/types.ts` is a leftover from the Redux era that only re-exports
|
||||
types from `initialState.ts`. New code should import types directly from
|
||||
`panel/initialState`; this shim exists for backward compatibility.
|
||||
|
||||
## Code Quality
|
||||
|
||||
- **Path aliases**: Use the `panel/*` alias for all `src/` imports
|
||||
(e.g. `import { apiClient } from 'panel/api/Api'`). 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
|
||||
co-located `*.module.pcss`. Sub-components go in a `blocks/` subfolder.
|
||||
- **Props**: Use `splitProps` from `solid-js` to separate local props from
|
||||
forwarded props (see `src/common/ui/Button`). Name the props type after the
|
||||
component (e.g. `ButtonProps`) and export it when consumers need it; prefer
|
||||
`type` over `interface`. Prefer explicit prop types over `any`.
|
||||
- **CSS Modules**: Import component styles as `s` — `import s from
|
||||
'./Foo.module.pcss'` — and reference classes as `s.button`, `s[variant]`.
|
||||
Compose conditional classes with `clsx`, imported as `cn`:
|
||||
`import cn from 'clsx'` (e.g. `cn(s.button, s.primary, { [s.active]: on() })`).
|
||||
- **Colors**: Never hardcode color hex values in `.pcss` or inline styles.
|
||||
Always reference the CSS custom properties defined in
|
||||
`src/common/styles/vars.css` and `src/common/styles/colors/*.css`
|
||||
(`var(--default-main-text)`, `var(--default-page-background)`, …). Light and
|
||||
dark themes are toggled via the `data-theme` attribute on `<html>`, so using
|
||||
the variables handles dark mode automatically.
|
||||
- **No inline styles**: Do not use the `style` attribute on elements. All
|
||||
styling belongs in co-located CSS Modules (`.module.pcss`) using class
|
||||
names. If a value must be dynamic, drive it through a CSS custom property or
|
||||
a conditional class via `cn(...)`, not an inline `style`. Exception: a single
|
||||
computed pixel offset for a dragging/positioning edge case may be acceptable
|
||||
when no class-based solution exists — justify it in a comment.
|
||||
- **Reactivity**: Use `createSignal` for local state, `createMemo` for
|
||||
derived values, `createEffect` for side effects, `onMount` for initial data
|
||||
fetches, and `onCleanup` for teardown. Do not read signals imperatively
|
||||
inside event handlers without `untrack` when needed.
|
||||
- **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`.
|
||||
- **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.
|
||||
- **Logging**: `console.warn` and `console.error` are allowed; `console.log`
|
||||
is disallowed by ESLint.
|
||||
- **Static analysis gates**: ESLint, Prettier, and `tsc --noEmit` must pass.
|
||||
`@typescript-eslint/no-explicit-any` is off (legacy), but prefer concrete
|
||||
types for new code. Unused vars must be prefixed with `_`.
|
||||
- **Do not modify linter/formatter configs** without justification; they
|
||||
enforce the conventions above.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit tests**: Vitest, configured in `vitest.config.ts` (jsdom environment,
|
||||
`vite-plugin-solid`). Test files live under `src/__tests__/` and match
|
||||
`*.{test,spec}.{ts,tsx}`.
|
||||
- **Component tests**: Use `@solidjs/testing-library` — `render(() => <Comp/>)`,
|
||||
`screen.getByTestId`, `fireEvent`/`userEvent`. Setup file
|
||||
`src/__tests__/setup.ts` auto-cleans after each test and adds
|
||||
`@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.
|
||||
- **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`
|
||||
(`testDir: ./tests/e2e`, base URL `http://127.0.0.1:3000`). Specs run against
|
||||
a real AdGuard Home backend prepared by `scripts/prepareConfig.mjs`. E2E
|
||||
tests are not part of `npm run check`; run them explicitly with
|
||||
`npm run test:e2e`.
|
||||
- **All unit tests must pass before merge** (`npm run test`).
|
||||
|
||||
## Dependency Management
|
||||
|
||||
- **Pin all dependency versions explicitly.** Do not introduce version ranges
|
||||
that allow automatic upgrades to untested versions.
|
||||
- **Prefer vanilla solutions.** Use the browser's built-in APIs (`fetch`,
|
||||
`crypto.randomUUID` via `nanoid`, CSS custom properties) when they adequately
|
||||
solve the problem. Only add a dependency when it provides significant value
|
||||
over a vanilla implementation.
|
||||
- **Reputable sources only.** Dependencies must come from well-established,
|
||||
actively maintained projects.
|
||||
- **Avoid unpopular libraries.** Do not add niche or obscure packages with
|
||||
limited community adoption.
|
||||
- **Minimize dependency count.** Each new dependency increases bundle size and
|
||||
maintenance burden; justify every addition.
|
||||
- **Use the latest stable version** when adding a new dependency — check the
|
||||
npm registry for the current stable release rather than copying a stale
|
||||
version.
|
||||
|
||||
**Rationale**: Fewer, well-vetted dependencies reduce security
|
||||
vulnerabilities, supply-chain risk, and long-term maintenance cost.
|
||||
|
||||
**Known exclusions** (to be fixed):
|
||||
|
||||
- Most dependencies in `package.json` use caret ranges (`^`) rather than exact
|
||||
pins. This should be tightened to exact versions over time.
|
||||
- `lodash` is a large dependency; prefer importing only the specific functions
|
||||
needed or replacing with native equivalents where possible.
|
||||
|
||||
## Configuration & Documentation
|
||||
|
||||
- **Runtime configuration**: The dev server (`webpack.dev.js`) reads the
|
||||
backend host/port from the root `AdguardHome.yaml` and proxies `/control`
|
||||
requests to it. The dev server runs on `backendPort + 8000`.
|
||||
- **Build configuration**: `BUILD_ENV` (`dev`/`prod`) is set via `cross-env`
|
||||
in npm scripts and read from `constants.js`. `BASE_URL = 'control'` is the
|
||||
API path prefix.
|
||||
- **Path aliases**: Defined in `tsconfig.json` (`panel/*`, `Twosky`) and
|
||||
consumed by Webpack, Vitest, and ESLint. Update `tsconfig.json` when adding
|
||||
new aliases.
|
||||
- **i18n**: Locale JSON files live in `src/__locales/`. The base locale and
|
||||
language list come from the root `.twosky.json` (via the `Twosky` alias in
|
||||
`src/helpers/twosky.ts`). Run `npm run translations:check` after editing
|
||||
translation files.
|
||||
- **Documentation to keep in sync**: Update this `AGENTS.md` Project Structure
|
||||
when directories change. Update `CHANGELOG.md` (root) for user-facing
|
||||
changes following the config in `changelog.config.js`.
|
||||
- **No secrets in code.** Never hardcode credentials, tokens, or API keys.
|
||||
The app authenticates at runtime via the backend login flow.
|
||||
|
||||
## Markdown Formatting
|
||||
|
||||
All Markdown files MUST follow these formatting rules:
|
||||
|
||||
- **Line length**: Keep lines at most 80 characters, but don't overwrap the
|
||||
lines artificially short just to hit the limit, keep them close to 80
|
||||
characters where possible. This is not a hard lint gate, but SHOULD be
|
||||
followed for readability. Lines inside fenced code blocks are exempt from
|
||||
this limit.
|
||||
- **Unordered lists**: Use dashes (`-`) for bullet points. Indent nested
|
||||
list items by 4 spaces.
|
||||
- **Continuation lines**: When a list item wraps to the next line,
|
||||
align the continuation with the first character of the item text,
|
||||
not the list marker. This applies to all list types (ordered and
|
||||
unordered).
|
||||
- **Emphasis**: Use asterisks (`*`) for emphasis (`*italic*`,
|
||||
`**bold**`). Do NOT use underscores.
|
||||
- **Headings**: Duplicate heading names are allowed only among sibling
|
||||
headings (same parent level). Avoid duplicates across different levels.
|
||||
- **Inline HTML**: Avoid raw HTML in Markdown. The only allowed elements
|
||||
are `<a>`, `<p>`, `<details>`, `<summary>`, and `<img>`.
|
||||
- **Trailing spaces**: Do NOT leave trailing whitespace on any line. Do
|
||||
NOT use two-space line breaks — use a blank line instead.
|
||||
- **Bare URLs**: Bare URLs are permitted and do not need to be wrapped
|
||||
in angle brackets.
|
||||
- **Table formatting**: Align table columns with padding when the table fits
|
||||
within 80 characters. If the table exceeds 80 characters or triggers an MD060
|
||||
linter warning, switch to a compact format using single spaces only. This
|
||||
applies to the separator row as well—it should be written as `| --- |`,
|
||||
not `|--|`.
|
||||
|
||||
Example of correct layout:
|
||||
|
||||
```markdown
|
||||
| Col1 | Col2 |
|
||||
| ------ | ------ |
|
||||
| Value1 | Value2 |
|
||||
```
|
||||
|
||||
Do NOT use extra padding or alignment characters beyond single spaces.
|
||||
|
||||
**Rationale**: Uniform Markdown formatting improves readability for both
|
||||
humans and AI agents that consume project documentation.
|
||||
|
||||
## Other
|
||||
|
||||
### Accessibility
|
||||
|
||||
All interactive components must be keyboard-operable and screen-reader
|
||||
friendly:
|
||||
|
||||
- Use semantic HTML controls for every interaction (`button`, `a`, `input`,
|
||||
`select`). Do **not** attach `onClick` to `div`, `span`, or icon elements —
|
||||
wrap them in a `<button>`.
|
||||
- Every interactive element must have an accessible name. Icon-only buttons
|
||||
must carry `aria-label={intl.getMessage('key')}` — the label string must be
|
||||
a dedicated translation key (see existing usage in `Pagination.tsx`,
|
||||
`CopiedText.tsx`, `Input.tsx`).
|
||||
- Visible labels pair a `<label htmlFor={id}>` with a matching input `id`.
|
||||
- Focusable rows acting as buttons must be keyboard-activatable: `tabindex` of
|
||||
`0` plus an `onKeyDown` handler for Enter/Space.
|
||||
- Any `id` used for labeling must be unique per rendered instance.
|
||||
|
||||
### Translations
|
||||
|
||||
- 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.
|
||||
- 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
|
||||
translation value as `%name%` placeholders (e.g.
|
||||
`intl.getMessage('client_blocked', { name })` → `"%name% blocked"`).
|
||||
- `intl.getPlural('key', number, values?)` — returns a pluralized string
|
||||
selected by `number`. Plural values are pipe-delimited into three forms:
|
||||
`zero | singular | plural`, each separated by `|`. The leading `|`
|
||||
denotes the zero form (used when `number === 0`); it is usually left
|
||||
empty, which is why values start with `|`. Use `%count%` (and other
|
||||
`%name%`) placeholders for the interpolated count, e.g.
|
||||
`"| %count% query total | %count% queries total"`. Always pass the count
|
||||
as the second argument and reuse the existing plural key rather than
|
||||
branching into separate `getMessage` calls (see
|
||||
`Dashboard/blocks/Header/Header.tsx`, `Settings/helpers.ts`).
|
||||
- **Never use dynamic keys.** Always pass a string literal as the `key`
|
||||
argument to `getMessage`/`getPlural`. Do not build keys from variables,
|
||||
template literals, or concatenation (e.g. `getMessage(\`btn\_\${action}\`)`).
|
||||
Dynamic keys cannot be statically detected, so the translation tooling will
|
||||
not pick them up and the strings will go untranslated.
|
||||
- Do not add duplicate keys to any locale file.
|
||||
- Run `npm run translations:check` after adding or editing translation keys.
|
||||
- Every `aria-*` string must have its own dedicated translation key.
|
||||
@@ -0,0 +1,321 @@
|
||||
# Development Guide — client_v3
|
||||
|
||||
This document explains how to set up a development environment for the
|
||||
`client_v3` frontend, run it locally, and contribute code. It is intended for
|
||||
developers working on the next-generation AdGuard Home web UI.
|
||||
|
||||
For code guidelines, architecture, and project structure, see
|
||||
[AGENTS.md](./AGENTS.md). For user-facing documentation, see the root
|
||||
[README.md](../README.md).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Development Guide — client_v3](#development-guide--client_v3)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Clone and Install](#clone-and-install)
|
||||
- [Running the Dev Server](#running-the-dev-server)
|
||||
- [Production Build](#production-build)
|
||||
- [Development Workflow](#development-workflow)
|
||||
- [Branching and Pull Requests](#branching-and-pull-requests)
|
||||
- [Code Style and Formatting](#code-style-and-formatting)
|
||||
- [Type Checking](#type-checking)
|
||||
- [Running Tests](#running-tests)
|
||||
- [Translations](#translations)
|
||||
- [Common Tasks](#common-tasks)
|
||||
- [Adding a Component](#adding-a-component)
|
||||
- [Adding a Store](#adding-a-store)
|
||||
- [Adding an API Method](#adding-an-api-method)
|
||||
- [Adding a Translation Key](#adding-a-translation-key)
|
||||
- [Debugging in VS Code](#debugging-in-vs-code)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Dev server cannot find `../AdguardHome.yaml`](#dev-server-cannot-find-adguardhomeyaml)
|
||||
- [API requests return 404 or connection refused](#api-requests-return-404-or-connection-refused)
|
||||
- [E2e tests fail to start](#e2e-tests-fail-to-start)
|
||||
- ["Multiple instances of Solid" errors in tests](#multiple-instances-of-solid-errors-in-tests)
|
||||
- [Port already in use](#port-already-in-use)
|
||||
- [Type errors after adding imports](#type-errors-after-adding-imports)
|
||||
- [Additional Resources](#additional-resources)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, install the following tools:
|
||||
|
||||
- **Node.js** 20 LTS or newer. The project uses `@types/node` 22; a current
|
||||
LTS release is recommended.
|
||||
- **npm** 10.x or newer (bundled with Node.js).
|
||||
- **An AdGuard Home backend** running locally. The dev server proxies
|
||||
`/control` API requests to it, so a built `AdGuardHome` binary in the
|
||||
repository root (or any reachable instance) is required for the UI to
|
||||
function. See the root [README.md](../README.md) and
|
||||
[HACKING.md](../HACKING.md) for building the Go backend.
|
||||
|
||||
No global packages are required — all tooling (Webpack, ESLint, Vitest,
|
||||
Playwright, TypeScript) is installed locally via `npm install`.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Clone and Install
|
||||
|
||||
All commands in this guide are run from the `client_v3/` directory.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/AdguardTeam/AdGuardHome.git
|
||||
cd AdGuardHome/client_v3
|
||||
npm install
|
||||
```
|
||||
|
||||
`npm install` also runs the Playwright browser install postinstall step. If it
|
||||
does not, run `npx playwright install` manually before running e2e tests.
|
||||
|
||||
### Running the Dev Server
|
||||
|
||||
The dev server reads the backend host and port from the root
|
||||
`AdguardHome.yaml` file (the `bind_host` and `bind_port` fields) and proxies
|
||||
`/control` requests to that backend. Make sure a backend config exists at
|
||||
`../AdguardHome.yaml` and that the AdGuard Home backend is running.
|
||||
|
||||
Start the dev server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
By default the dev server listens on `bind_port + 8000` (for example, if the
|
||||
backend runs on port `3000`, the dev server runs on `11000`). Override the
|
||||
port with the `DEV_SERVER_PORT` environment variable:
|
||||
|
||||
```sh
|
||||
DEV_SERVER_PORT=8080 npm run dev
|
||||
```
|
||||
|
||||
The server opens the main dashboard automatically. The four HTML entry points
|
||||
are:
|
||||
|
||||
- `main` — the dashboard / control panel (HashRouter SPA)
|
||||
- `install.html` — the first-run setup wizard
|
||||
- `login.html` — the sign-in page
|
||||
- `forgot_password.html` — the password-reset flow
|
||||
|
||||
If the backend is at `http://127.0.0.1:3000`, the dev server is at
|
||||
`http://127.0.0.1:11000`, and you can open `install.html`, `login.html`, or
|
||||
`forgot_password.html` directly.
|
||||
|
||||
### Production Build
|
||||
|
||||
The production build emits static assets into `../build/static/`, which the Go
|
||||
binary embeds at compile time.
|
||||
|
||||
```sh
|
||||
npm run build-prod
|
||||
```
|
||||
|
||||
To produce a development build without starting a server:
|
||||
|
||||
```sh
|
||||
npm run build-dev
|
||||
```
|
||||
|
||||
To watch for changes and rebuild continuously:
|
||||
|
||||
```sh
|
||||
npm run watch
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Branching and Pull Requests
|
||||
|
||||
- Create a feature branch from `master` (for example,
|
||||
`feature/my-new-feature`).
|
||||
- Keep changes focused; one concern per pull request.
|
||||
- Ensure the full check passes before opening a PR (see
|
||||
[Running Tests](#running-tests)).
|
||||
- Follow the contribution workflow described in the root
|
||||
[CONTRIBUTING.md](../CONTRIBUTING.md).
|
||||
|
||||
### Code Style and Formatting
|
||||
|
||||
Lint the source:
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Auto-fix lint issues and format with Prettier:
|
||||
|
||||
```sh
|
||||
npm run lint:fix
|
||||
```
|
||||
|
||||
Linting and formatting rules are defined in `.eslintrc` files and `.prettierrc`.
|
||||
Do not modify these configs without justification. For the full set of code
|
||||
conventions (path aliases, component structure, CSS modules, reactivity, etc.),
|
||||
see [AGENTS.md](./AGENTS.md).
|
||||
|
||||
### Type Checking
|
||||
|
||||
Run the TypeScript compiler in check-only mode:
|
||||
|
||||
```sh
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
Watch mode for continuous type checking:
|
||||
|
||||
```sh
|
||||
npm run typecheck:watch
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
Run the full local gate (lint + typecheck + unit tests) with a single command:
|
||||
|
||||
```sh
|
||||
npm run check
|
||||
```
|
||||
|
||||
Unit tests (Vitest, jsdom environment):
|
||||
|
||||
```sh
|
||||
npm run test # single run
|
||||
npm run test:watch # watch mode
|
||||
```
|
||||
|
||||
Unit tests live under `src/__tests__/` and match `*.{test,spec}.{ts,tsx}`.
|
||||
Mirror the source path under `__tests__/` when adding new tests.
|
||||
|
||||
End-to-end tests (Playwright):
|
||||
|
||||
```sh
|
||||
npm run test:e2e # run all e2e specs
|
||||
npm run test:e2e:interactive # open the Playwright UI
|
||||
npm run test:e2e:debug # debug mode
|
||||
npm run test:e2e:codegen # generate tests by recording actions
|
||||
```
|
||||
|
||||
E2E specs live in `tests/e2e/`. They run against a real AdGuard Home backend
|
||||
that Playwright starts automatically (see the `webServer` config in
|
||||
`playwright.config.ts`). The `./AdGuardHome` binary must be present in the
|
||||
repository root for this to work. E2E tests are **not** part of
|
||||
`npm run check`; run them explicitly.
|
||||
|
||||
### Translations
|
||||
|
||||
The base locale is `src/__locales/en.json`. After adding or editing
|
||||
translation keys, verify consistency:
|
||||
|
||||
```sh
|
||||
npm run translations:check
|
||||
```
|
||||
|
||||
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`).
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a Component
|
||||
|
||||
1. Create a PascalCase directory under `src/components/` (or
|
||||
`src/common/controls/` for primitives, `src/common/ui/` for higher-level
|
||||
UI).
|
||||
2. Add an `index.tsx` and a co-located `*.module.pcss`.
|
||||
3. Place sub-components in a `blocks/` subfolder.
|
||||
4. Import styles as `s` and compose classes with `clsx` (imported as `cn`).
|
||||
5. Use the `panel/*` path alias for all `src/` imports.
|
||||
6. Add a co-located unit test under `src/__tests__/`.
|
||||
|
||||
See the [Code Quality](./AGENTS.md#code-quality) section of `AGENTS.md` for
|
||||
the full set of conventions.
|
||||
|
||||
### Adding a Store
|
||||
|
||||
1. Create a `camelCase.ts` module under `src/stores/` with a module-scoped
|
||||
`createStore` singleton.
|
||||
2. Async actions set a `processing*` flag, call `apiClient`, then `setState`.
|
||||
3. Surface errors via `addErrorToast` from the toasts store.
|
||||
4. Add a test under `src/__tests__/stores/`.
|
||||
|
||||
### Adding an API Method
|
||||
|
||||
Add a typed method to the `Api` class in `src/api/Api.ts`. All HTTP calls must
|
||||
go through this singleton — do not call `fetch` directly from components or
|
||||
stores.
|
||||
|
||||
### Adding a Translation Key
|
||||
|
||||
1. Add the key to `src/__locales/en.json`.
|
||||
2. Use it via `intl.getMessage('key', values?)` or
|
||||
`intl.getPlural('key', number, values?)`.
|
||||
3. Never build keys dynamically — always pass a string literal.
|
||||
4. Run `npm run translations:check` to verify.
|
||||
|
||||
### Debugging in VS Code
|
||||
|
||||
- The dev server runs Webpack Dev Server with hot module replacement and
|
||||
source maps (`eval-source-map`).
|
||||
- Set breakpoints directly in `src/` files; VS Code maps them to the bundled
|
||||
output automatically.
|
||||
- For unit tests, use the Vitest VS Code extension or run
|
||||
`npm run test:watch` and attach a debugger to the Vitest process.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Dev server cannot find `../AdguardHome.yaml`
|
||||
|
||||
The dev server reads the backend host and port from the root
|
||||
`AdguardHome.yaml`. Make sure the file exists at the repository root and
|
||||
contains `bind_host` and `bind_port`. If it is missing, the server falls back
|
||||
to `0.0.0.0:80` and the proxy will not reach the backend. Start the backend or
|
||||
create the config file.
|
||||
|
||||
### API requests return 404 or connection refused
|
||||
|
||||
The dev server proxies `/control` to the backend at `bind_host:bind_port`.
|
||||
Verify the AdGuard Home backend is running and that the port in
|
||||
`AdguardHome.yaml` matches the running instance.
|
||||
|
||||
### E2e tests fail to start
|
||||
|
||||
E2e tests require the `./AdGuardHome` binary in the repository root. Build the
|
||||
backend first (see [HACKING.md](../HACKING.md)). Playwright starts the backend
|
||||
automatically; if a server is already running on port 3000, stop it first:
|
||||
|
||||
```sh
|
||||
kill $(lsof -ti :3000)
|
||||
```
|
||||
|
||||
### "Multiple instances of Solid" errors in tests
|
||||
|
||||
The Vitest config (see `vitest.config.ts`) forces a single `solid-js` instance
|
||||
via `dedupe` and `ssr.noExternal`. If you add a dependency that pulls in its
|
||||
own copy of `solid-js`, add it to the `dedupe` and `noExternal` lists.
|
||||
|
||||
### Port already in use
|
||||
|
||||
Stop any process occupying the dev server port or override it with
|
||||
`DEV_SERVER_PORT`:
|
||||
|
||||
```sh
|
||||
kill $(lsof -ti :11000)
|
||||
DEV_SERVER_PORT=8080 npm run dev
|
||||
```
|
||||
|
||||
### Type errors after adding imports
|
||||
|
||||
Use the `panel/*` alias (`panel/api/Api`, `panel/initialState`, etc.) instead
|
||||
of deep relative paths. The alias is defined in `tsconfig.json` and consumed
|
||||
by Webpack, Vitest, and ESLint.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [AGENTS.md](./AGENTS.md) — code guidelines, architecture, and project
|
||||
structure
|
||||
- [README.md](../README.md) — AdGuard Home project overview and user manual
|
||||
- [HACKING.md](../HACKING.md) — AdGuard Home developer guidelines (Go backend)
|
||||
- [CONTRIBUTING.md](../CONTRIBUTING.md) — contribution workflow
|
||||
- [CHANGELOG.md](../CHANGELOG.md) — changelog
|
||||
- [AdGuard Code Guidelines](https://github.com/AdguardTeam/CodeGuidelines) —
|
||||
general code style guidelines
|
||||
@@ -0,0 +1,10 @@
|
||||
module.exports = (api) => {
|
||||
api.cache(false);
|
||||
return {
|
||||
presets: [
|
||||
'@babel/preset-env',
|
||||
'@babel/preset-typescript',
|
||||
'babel-preset-solid',
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export const BUILD_ENVS = {
|
||||
dev: 'development',
|
||||
prod: 'production',
|
||||
};
|
||||
|
||||
export const BASE_URL = 'control';
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": ".eslintrc",
|
||||
"rules": {
|
||||
"no-debugger":"warn"
|
||||
}
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
declare module '*.svg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*.module.pcss' {
|
||||
const classes: { [key: string]: string };
|
||||
export default classes;
|
||||
}
|
||||
|
||||
declare module '*.pcss' {
|
||||
const classes: { [key: string]: string };
|
||||
export default classes;
|
||||
}
|
||||
Generated
+16147
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"name": "dashboard",
|
||||
"version": "3.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_ENV=development BUILD_ENV=dev webpack-dev-server --config webpack.dev.js",
|
||||
"build-dev": "cross-env BUILD_ENV=dev webpack --config webpack.dev.js",
|
||||
"build-prod": "cross-env BUILD_ENV=prod webpack --config webpack.prod.js",
|
||||
"check": "npm run lint && npm run typecheck && npm run test",
|
||||
"watch": "cross-env BUILD_ENV=dev webpack --config webpack.dev.js --watch",
|
||||
"start": "cross-env NODE_ENV=development BUILD_ENV=dev webpack-dev-server --config webpack.dev.js",
|
||||
"lint": "eslint --ext .ts,.tsx src",
|
||||
"lint:fix": "prettier --write \"src/**/*.{ts,tsx,json}\" && eslint --ext .ts,.tsx src --fix",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest --watch",
|
||||
"test:e2e": "npx playwright test tests/e2e",
|
||||
"test:e2e:interactive": "npx playwright test --ui",
|
||||
"test:e2e:debug": "npx playwright test --debug",
|
||||
"test:e2e:codegen": "npx playwright codegen",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:watch": "tsc --noEmit --watch",
|
||||
"translations:check": "node ./scripts/check-translations.js"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@adguard/translate": "^1.0.2",
|
||||
"@ark-ui/solid": "^5.37.1",
|
||||
"@modular-forms/solid": "^0.25.0",
|
||||
"@solidjs/router": "^0.15.0",
|
||||
"chart.js": "^4.4.0",
|
||||
"clsx": "^2.1.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"countries-and-timezones": "^3.6.0",
|
||||
"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",
|
||||
"solid-transition-group": "^0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.5",
|
||||
"@babel/preset-env": "^7.24.5",
|
||||
"@babel/preset-typescript": "^7.24.1",
|
||||
"@playwright/test": "1.56.0",
|
||||
"@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",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"babel-loader": "^9.1.3",
|
||||
"babel-preset-solid": "^1.9.0",
|
||||
"clean-webpack-plugin": "^4.0.0",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"css-loader": "^7.1.2",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.5",
|
||||
"eslint-plugin-import": "^2.29.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-solid": "^0.14.0",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"mini-css-extract-plugin": "^2.9.0",
|
||||
"playwright": "^1.61.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-import": "^16.1.1",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"postcss-nested": "^7.0.2",
|
||||
"prettier": "^3.2.5",
|
||||
"stylelint": "^16.5.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-solid": "^2.11.12",
|
||||
"vitest": "^4.1.0",
|
||||
"webpack": "^5.91.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^5.0.4",
|
||||
"webpack-merge": "^5.10.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
],
|
||||
"production": [
|
||||
"defaults",
|
||||
"not dead",
|
||||
"not ie 11",
|
||||
"not op_mini all"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { ADMIN_USERNAME, CONFIG_FILE_PATH, PORT, WORK_DIR_PATH } from './tests/constants';
|
||||
|
||||
const configDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ADMIN_PASSWORD_HASH = '$2a$10$82RqoFQEf8GcFZwhCk4GFu.KHavhWaNajpZxCkdYsHhToNRe8ljO2';
|
||||
const DNS_PORT = 5353;
|
||||
const CONFIG_SCHEMA_VERSION = 34;
|
||||
|
||||
const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
|
||||
const prepareConfigScriptPath = path.resolve(configDir, './tests/e2e/prepareConfig.mjs');
|
||||
const prepareConfigEnv = [
|
||||
`E2E_CONFIG_PATH=${shellQuote(CONFIG_FILE_PATH)}`,
|
||||
`E2E_WORK_DIR=${shellQuote(WORK_DIR_PATH)}`,
|
||||
`E2E_ADMIN_USERNAME=${shellQuote(ADMIN_USERNAME)}`,
|
||||
`E2E_ADMIN_PASSWORD_HASH=${shellQuote(ADMIN_PASSWORD_HASH)}`,
|
||||
`E2E_HTTP_PORT=${shellQuote(String(PORT))}`,
|
||||
`E2E_DNS_PORT=${shellQuote(String(DNS_PORT))}`,
|
||||
`E2E_SCHEMA_VERSION=${shellQuote(String(CONFIG_SCHEMA_VERSION))}`,
|
||||
].join(' ');
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
globalSetup: path.resolve(configDir, './tests/e2e/globalSetup.ts'),
|
||||
globalTeardown: path.resolve(configDir, './tests/e2e/globalTeardown.ts'),
|
||||
timeout: 30000,
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: 'http://127.0.0.1:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
launchOptions: {
|
||||
headless: true,
|
||||
},
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
stdout: process.env.CI ? 'pipe' : 'ignore',
|
||||
command: `${prepareConfigEnv} node ${shellQuote(prepareConfigScriptPath)} && ./AdGuardHome --local-frontend -v -c ${CONFIG_FILE_PATH} --work-dir ${WORK_DIR_PATH}`,
|
||||
url: 'http://127.0.0.1:3000',
|
||||
cwd: path.resolve(configDir, '..'),
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 50000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import autoprefixer from 'autoprefixer';
|
||||
import postcssImport from 'postcss-import';
|
||||
import postcssNested from 'postcss-nested';
|
||||
|
||||
export default {
|
||||
plugins: [postcssImport(), postcssNested(), autoprefixer()],
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"rules": {
|
||||
// disallow the use of debugger
|
||||
"no-debugger": "error",
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2503_38161)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.0001 1.14819C10.5835 1.14819 5.55144 2.74857 1.37676 5.48949C1.09039 10.4753 2.22047 15.6334 4.92875 20.3243C7.63144 25.0055 11.5229 28.5582 15.9725 30.8039L16.0001 30.8518L16.0277 30.8039C20.4772 28.5582 24.3687 25.0055 27.0714 20.3243C29.7797 15.6335 30.9098 10.4754 30.6234 5.48949C26.4487 2.74857 21.4166 1.14819 16.0001 1.14819Z" fill="url(#paint0_linear_2503_38161)"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.6147 11.921C21.0632 12.4218 21.062 13.2323 20.6119 13.7314L17.1768 17.5409C16 18.9704 15.2574 18.9704 14.0336 17.5409L11.8825 15.1554C11.4324 14.6563 11.4312 13.8457 11.8797 13.345C12.3283 12.8442 13.0567 12.8428 13.5068 13.3419L15.6052 15.669L18.9876 11.9179C19.4377 11.4188 20.1661 11.4202 20.6147 11.921Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_2503_38161" x1="16.0001" y1="1.14819" x2="16.0001" y2="30.8518" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#67B279"/>
|
||||
<stop offset="1" stop-color="#589968"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_2503_38161">
|
||||
<rect width="32" height="32" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no" />
|
||||
<meta name="google" content="notranslate" />
|
||||
<link rel="apple-touch-icon" href="assets/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="assets/favicon.ico" />
|
||||
<title>Forgot Password</title>
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<script>
|
||||
(function () {
|
||||
var prefersDark =
|
||||
window.matchMedia &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var theme = prefersDark ? 'dark' : 'light';
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,45 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no" />
|
||||
<meta name="google" content="notranslate" />
|
||||
<meta http-equiv="x-dns-prefetch-control" content="off" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<link rel="apple-touch-icon" href="assets/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="assets/favicon.ico" />
|
||||
<title>AdGuard Home</title>
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<script>
|
||||
(function () {
|
||||
var THEME_KEY = 'account_theme';
|
||||
var theme = null;
|
||||
try {
|
||||
var raw = window.localStorage.getItem(THEME_KEY);
|
||||
if (raw) {
|
||||
try {
|
||||
theme = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
theme = raw;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
if (!theme || theme === 'auto') {
|
||||
var prefersDark =
|
||||
window.matchMedia &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
theme = prefersDark ? 'dark' : 'light';
|
||||
}
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript> You need to enable JavaScript to run this app. </noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no" />
|
||||
<meta name="google" content="notranslate" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<link rel="apple-touch-icon" href="assets/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="assets/favicon.ico" />
|
||||
<title>Setup AdGuard Home</title>
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<script>
|
||||
(function () {
|
||||
var prefersDark =
|
||||
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var theme = prefersDark ? 'dark' : 'light';
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript> You need to enable JavaScript to run this app. </noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no" />
|
||||
<meta name="google" content="notranslate" />
|
||||
<link rel="apple-touch-icon" href="assets/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="assets/favicon.ico" />
|
||||
<title>Login</title>
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<script>
|
||||
(function () {
|
||||
var prefersDark =
|
||||
window.matchMedia &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var theme = prefersDark ? 'dark' : 'light';
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,50 @@
|
||||
// CLI entry point for auditing client_v3 translation usage against src/__locales/en.json.
|
||||
// It reports missing locale keys, unused locale keys, and dynamic intl usages that make the audit best-effort.
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
auditTranslations,
|
||||
collectTranslationUsageFromFiles,
|
||||
formatAuditReport,
|
||||
listSourceFiles,
|
||||
loadLocaleMessages,
|
||||
} from './translation-audit.js';
|
||||
|
||||
export const runTranslationAudit = async ({
|
||||
rootDir = process.cwd(),
|
||||
write = (chunk) => {
|
||||
process.stdout.write(chunk);
|
||||
},
|
||||
writeError = (chunk) => {
|
||||
process.stderr.write(chunk);
|
||||
},
|
||||
} = {}) => {
|
||||
try {
|
||||
const srcDir = path.join(rootDir, 'src');
|
||||
const localePath = path.join(srcDir, '__locales', 'en.json');
|
||||
const filePaths = await listSourceFiles(srcDir);
|
||||
const localeMessages = await loadLocaleMessages(localePath);
|
||||
const usage = await collectTranslationUsageFromFiles(filePaths);
|
||||
const report = auditTranslations({ localeMessages, usage });
|
||||
|
||||
write(`${formatAuditReport(report, { rootDir })}\n`);
|
||||
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
writeError(`Translation audit failed: ${message}\n`);
|
||||
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
const currentFilePath = fileURLToPath(import.meta.url);
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === currentFilePath) {
|
||||
const exitCode = await runTranslationAudit();
|
||||
|
||||
process.exitCode = exitCode;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import jscodeshift from 'jscodeshift';
|
||||
|
||||
const j = jscodeshift.withParser('tsx');
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx']);
|
||||
const TRANSLATION_METHODS = new Set(['getMessage', 'getPlural']);
|
||||
|
||||
const isIntlMethod = (callee) =>
|
||||
callee?.type === 'MemberExpression'
|
||||
&& !callee.computed
|
||||
&& callee.object?.type === 'Identifier'
|
||||
&& callee.object.name === 'intl'
|
||||
&& callee.property?.type === 'Identifier'
|
||||
&& TRANSLATION_METHODS.has(callee.property.name);
|
||||
|
||||
const getLiteralKey = (argument) => {
|
||||
if (!argument) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (argument.type === 'StringLiteral') {
|
||||
return argument.value;
|
||||
}
|
||||
|
||||
if (argument.type === 'Literal' && typeof argument.value === 'string') {
|
||||
return argument.value;
|
||||
}
|
||||
|
||||
if (argument.type === 'TemplateLiteral' && argument.expressions.length === 0) {
|
||||
return argument.quasis[0]?.value.cooked ?? '';
|
||||
}
|
||||
|
||||
if (argument.type === 'ParenthesizedExpression') {
|
||||
return getLiteralKey(argument.expression);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const sortByFileAndLine = (left, right) =>
|
||||
left.filePath.localeCompare(right.filePath)
|
||||
|| left.line - right.line
|
||||
|| left.method.localeCompare(right.method);
|
||||
|
||||
const toRelativePath = (filePath, rootDir) => {
|
||||
if (!rootDir) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const relativePath = path.relative(rootDir, filePath);
|
||||
|
||||
return relativePath === '' ? '.' : relativePath;
|
||||
};
|
||||
|
||||
const formatNodeSource = (node) => j(node).toSource({ quote: 'single' });
|
||||
|
||||
export const collectTranslationUsageFromSource = (source, filePath) => {
|
||||
const staticKeys = [];
|
||||
const dynamicUsages = [];
|
||||
|
||||
j(source)
|
||||
.find(j.CallExpression)
|
||||
.forEach((callPath) => {
|
||||
const { node } = callPath;
|
||||
|
||||
if (!isIntlMethod(node.callee)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const method = node.callee.property.name;
|
||||
const line = node.loc?.start?.line ?? 0;
|
||||
const key = getLiteralKey(node.arguments[0]);
|
||||
|
||||
if (key !== null) {
|
||||
staticKeys.push({ filePath, key, line, method });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
dynamicUsages.push({
|
||||
expression: formatNodeSource(node),
|
||||
filePath,
|
||||
line,
|
||||
method,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
dynamicUsages: dynamicUsages.sort(sortByFileAndLine),
|
||||
staticKeys: staticKeys.sort(sortByFileAndLine),
|
||||
};
|
||||
};
|
||||
|
||||
export const auditTranslations = ({ localeMessages, usage }) => {
|
||||
const localeKeys = Object.keys(localeMessages);
|
||||
const usedStaticKeySet = new Set(usage.staticKeys.map((entry) => entry.key));
|
||||
const missingKeys = [...usedStaticKeySet].filter((key) => !Object.hasOwn(localeMessages, key)).sort();
|
||||
const unusedKeys = localeKeys.filter((key) => !usedStaticKeySet.has(key)).sort();
|
||||
|
||||
return {
|
||||
dynamicUsages: [...usage.dynamicUsages].sort(sortByFileAndLine),
|
||||
localeKeyCount: localeKeys.length,
|
||||
missingKeys,
|
||||
resultsAreIncomplete: usage.dynamicUsages.length > 0,
|
||||
staticKeyCount: usage.staticKeys.length,
|
||||
unusedKeys,
|
||||
};
|
||||
};
|
||||
|
||||
export const formatAuditReport = (report, { rootDir } = {}) => {
|
||||
const lines = [
|
||||
'client_v3 translation audit',
|
||||
`Static translation calls: ${report.staticKeyCount}`,
|
||||
`Locale keys in en.json: ${report.localeKeyCount}`,
|
||||
`Dynamic translation calls: ${report.dynamicUsages.length}`,
|
||||
'',
|
||||
];
|
||||
|
||||
if (report.resultsAreIncomplete) {
|
||||
lines.push(
|
||||
'WARNING: Dynamic translation usages were found. Missing and unused results are best-effort until those calls are rewritten with string literals.',
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
if (report.missingKeys.length === 0) {
|
||||
lines.push('Missing translation keys: none', '');
|
||||
} else {
|
||||
lines.push('Missing translation keys:');
|
||||
report.missingKeys.forEach((key) => {
|
||||
lines.push(`- ${key}`);
|
||||
});
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (report.unusedKeys.length === 0) {
|
||||
lines.push('Unused translation keys: none', '');
|
||||
} else {
|
||||
lines.push('Unused translation keys:');
|
||||
report.unusedKeys.forEach((key) => {
|
||||
lines.push(`- ${key}`);
|
||||
});
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (report.dynamicUsages.length === 0) {
|
||||
lines.push('Dynamic translation usages: none');
|
||||
} else {
|
||||
lines.push('Dynamic translation usages:');
|
||||
report.dynamicUsages.forEach((entry) => {
|
||||
lines.push(`- ${toRelativePath(entry.filePath, rootDir)}:${entry.line} ${entry.expression}`);
|
||||
});
|
||||
}
|
||||
|
||||
return lines.join('\n').trimEnd();
|
||||
};
|
||||
|
||||
export const loadLocaleMessages = async (localePath) => {
|
||||
const fileContents = await fs.readFile(localePath, 'utf8');
|
||||
|
||||
return JSON.parse(fileContents);
|
||||
};
|
||||
|
||||
export const listSourceFiles = async (directory) => {
|
||||
const entries = await fs.readdir(directory, { withFileTypes: true });
|
||||
const files = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === '__tests__') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return listSourceFiles(entryPath);
|
||||
}
|
||||
|
||||
if (!SOURCE_EXTENSIONS.has(path.extname(entry.name)) || entry.name.endsWith('.d.ts')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [entryPath];
|
||||
}),
|
||||
);
|
||||
|
||||
return files.flat().sort();
|
||||
};
|
||||
|
||||
export const collectTranslationUsageFromFiles = async (filePaths) => {
|
||||
const usage = {
|
||||
dynamicUsages: [],
|
||||
staticKeys: [],
|
||||
};
|
||||
|
||||
const fileUsages = await Promise.all(
|
||||
filePaths.map(async (filePath) => {
|
||||
const source = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
return collectTranslationUsageFromSource(source, filePath);
|
||||
}),
|
||||
);
|
||||
|
||||
fileUsages.forEach((fileUsage) => {
|
||||
usage.staticKeys.push(...fileUsage.staticKeys);
|
||||
usage.dynamicUsages.push(...fileUsage.dynamicUsages);
|
||||
});
|
||||
|
||||
usage.staticKeys.sort(sortByFileAndLine);
|
||||
usage.dynamicUsages.sort(sortByFileAndLine);
|
||||
|
||||
return usage;
|
||||
};
|
||||
@@ -0,0 +1,362 @@
|
||||
{
|
||||
"access_settings_title": "Zugriffsrechte",
|
||||
"actions_label": "Aktionen",
|
||||
"actions_table_header": "Aktionen",
|
||||
"add": "Hinzufügen",
|
||||
"add_allowlist": "Zulassungsliste hinzufügen",
|
||||
"add_blocklist": "Blockliste hinzufügen",
|
||||
"add_persistent_client": "Als dauerhaften Client hinzufügen",
|
||||
"add_to_allowlist": "Zur Positivliste hinzufügen",
|
||||
"ads_blocked": "Anzeigen blockiert",
|
||||
"ads_blocked_card": "Anzeigen blockiert",
|
||||
"adult_websites_blocked_tooltip": "Anzahl der gesperrten Websites mit jugendgefährdenden Inhalten",
|
||||
"all_lists_up_to_date_toast": "Alle Listen sind bereits auf dem neuesten Stand",
|
||||
"allowed": "Auf Freigabeliste",
|
||||
"allowlist_add": "Zulassungsliste hinzufügen",
|
||||
"allowlist_edit": "Zulassungsliste bearbeiten",
|
||||
"apply": "Anwenden",
|
||||
"auto_clients_title": "Laufzeit-Clients",
|
||||
"autofix_warning_result": "Als Folge daraus werden alle DNS-Anforderungen von Ihrem System standardmäßig von AdGuardHome verarbeitet.",
|
||||
"autofix_warning_text": "Wenn Sie auf „Beheben“ klicken, konfiguriert AdGuardHome Ihr System für die Verwendung des AdGuardHome-DNS-Servers.",
|
||||
"average_time_processing": "Durchschnittliche Bearbeitungsdauer",
|
||||
"average_upstream_response_time": "Durchschnittliche Upstream-Antwortzeit",
|
||||
"back": "Zurück",
|
||||
"block": "Sperren",
|
||||
"block_for_this_client_only": "Nur für diesen Client sperren",
|
||||
"blocked": "Blockiert",
|
||||
"blocked_by_cname_or_ip": "Gesperrt durch CNAME oder IP",
|
||||
"blocked_service": "Gesperrte Dienste",
|
||||
"blocked_services": "Gesperrte Dienste",
|
||||
"blocked_threats": "Gesperrte Bedrohungen",
|
||||
"blocked_threats_chart": "Gesperrte Bedrohungen",
|
||||
"blocklist_add_manual": "Manuell hinzufügen",
|
||||
"blocklist_edit": "Blockliste bearbeiten",
|
||||
"blocklist_homepage": "Startseite",
|
||||
"blocklist_url_file_path": "URL oder Dateipfad",
|
||||
"blocklists_add": "Blockliste hinzufügen",
|
||||
"bootstrap_dns": "Bootstrap-DNS-Server",
|
||||
"cache_cleared": "DNS-Cache erfolgreich geleert",
|
||||
"cache_config_clear": "Cache leeren",
|
||||
"cache_config_optimistic": "Optimistisches Caching",
|
||||
"cache_config_ttl_validation": "Der Überschreibungswert für die minimale TTL muss kleiner oder gleich dem für die maximale TTL sein",
|
||||
"cache_enabled": "Cache aktivieren",
|
||||
"cancel": "Abbrechen",
|
||||
"cancel_btn": "Abbrechen",
|
||||
"category_tooltip": "Kategorie",
|
||||
"changes_saved_success": "Änderungen gespeichert",
|
||||
"check_dhcp_servers": "Auf DHCP-Server prüfen",
|
||||
"check_not_found": "Nicht in Ihren Filterlisten enthalten",
|
||||
"check_updates_btn": "Nach Updates suchen",
|
||||
"clear_btn": "Leeren",
|
||||
"client_add": "Client hinzufügen",
|
||||
"client_details": "Einzelheiten des Clients",
|
||||
"client_id": "Client-ID",
|
||||
"client_id_placeholder": "Client-ID eingeben",
|
||||
"client_identifier": "Bezeichner",
|
||||
"client_settings": "Client-Einstellungen",
|
||||
"client_table_header": "Client",
|
||||
"clients_add_identifier": "Kennung hinzufügen",
|
||||
"clients_dns_cache_size": "Größe des DNS-Cache, in Bytes",
|
||||
"clients_not_found": "Keine Clients gefunden",
|
||||
"clients_protection": "Schutz",
|
||||
"clients_tags": "Schlagwörter",
|
||||
"clients_title": "Persistente Clients",
|
||||
"clients_use_global_settings": "Allgemeine Einstellungen nutzen",
|
||||
"close": "Schließen",
|
||||
"confirm": "Bestätigen",
|
||||
"copied": "Kopiert",
|
||||
"copy": "Kopieren",
|
||||
"custom_filter_rules": "Benutzerdefinierte Filterregeln",
|
||||
"custom_filtering_rules": "Benutzerdefinierte Filterregeln",
|
||||
"dark_theme": "Dunkles Design",
|
||||
"dashboard": "Übersicht",
|
||||
"delete_btn": "Löschen",
|
||||
"delete_table_action": "Löschen",
|
||||
"dhcp_add_static_lease": "Statische Zuweisung hinzufügen",
|
||||
"dhcp_config_saved": "DHCP-Konfiguration erfolgreich gespeichert",
|
||||
"dhcp_edit_static_lease": "Statische Zuweisung bearbeiten",
|
||||
"dhcp_form_gateway_input": "Gateway-IP",
|
||||
"dhcp_form_lease_title": "DHCP-Zuweisungs-Dauer (in Sekunden)",
|
||||
"dhcp_form_subnet_input": "Subnetz-Maske",
|
||||
"dhcp_hardware_address": "Hardware-Adresse",
|
||||
"dhcp_interface_select": "DHCP-Benutzeroberfläche auswählen",
|
||||
"dhcp_ip_addresses": "IP-Adressen",
|
||||
"dhcp_leases_not_found": "Keine DHCP-Zuweisungen gefunden",
|
||||
"dhcp_new_static_lease": "Neue statische Zuweisung",
|
||||
"dhcp_reset_leases": "Alle Zuweisungen zurücksetzen",
|
||||
"dhcp_reset_leases_success": "DHCP-Zuweisungen erfolgreich zurückgesetzt",
|
||||
"dhcp_settings": "DHCP-Einstellungen",
|
||||
"dhcp_static_leases_not_found": "Keine statischen DHCP-Zuweisungen gefunden",
|
||||
"dhcp_table_hostname": "Hostname",
|
||||
"dhcp_table_ip_address": "IP-Adresse",
|
||||
"dhcp_warning": "Wenn Sie den DHCP-Server trotzdem aktivieren möchten, stellen Sie sicher, dass sich in Ihrem Netzwerk kein anderer aktiver DHCP-Server befindet. Andernfalls kann es bei angeschlossenen Geräten zu einem Ausfall des Internets kommen!",
|
||||
"disable": "Deaktivieren",
|
||||
"disable_notify_until_tomorrow": "Schutz bis morgen deaktivieren",
|
||||
"disable_protection_btn": "Schutz deaktivieren",
|
||||
"disabled_dhcp": "DHCP-Server deaktiviert",
|
||||
"disabled_protection": "Schutz deaktiviert",
|
||||
"disallow_this_client": "Diesen Client sperren",
|
||||
"dns_cache_config": "Konfiguration des DNS-Cache",
|
||||
"dns_config": "DNS-Serverkonfiguration",
|
||||
"dns_over_https": "DNS-over-HTTPS",
|
||||
"dns_over_quic": "DNS-over-QUIC",
|
||||
"dns_over_tls": "DNS-over-TLS",
|
||||
"dns_privacy": "DNS-Datenschutz",
|
||||
"dns_query": "DNS-Anfragen",
|
||||
"dns_rewrites": "DNS-Umschreibungen",
|
||||
"dns_settings": "DNS-Einstellungen",
|
||||
"dns_test_ok_toast": "Angegebene DNS-Server arbeiten ordnungsgemäß",
|
||||
"domain": "Domain",
|
||||
"domain_or_client": "Domain oder Client",
|
||||
"down": "Nicht erreichbar",
|
||||
"download_mobileconfig": "Konfigurationsdatei herunterladen",
|
||||
"edit_table_action": "Bearbeiten",
|
||||
"enable": "Aktivieren",
|
||||
"enabled_dhcp": "DHCP-Server aktiviert",
|
||||
"enabled_protection": "Schutz aktiviert",
|
||||
"enabled_table_header": "Aktiviert",
|
||||
"encryption_certificate_path": "Zertifikatspfad",
|
||||
"encryption_certificates": "Zertifikate",
|
||||
"encryption_certificates_input": "Kopieren Sie Ihre PEM-codierten Zertifikate und fügen Sie sie hier ein.",
|
||||
"encryption_certificates_source_content": "Inhalt des Zertifikats einfügen",
|
||||
"encryption_certificates_source_path": "Pfad für die Zertifikatsdatei festlegen",
|
||||
"encryption_chain_invalid": "Zertifikatskette ist ungültig",
|
||||
"encryption_chain_valid": "Zertifikatskette ist gültig",
|
||||
"encryption_config_saved": "Verschlüsselungskonfiguration gespeichert",
|
||||
"encryption_doq": "Port für DNS-over-QUIC",
|
||||
"encryption_dot": "DNS-over-TLS",
|
||||
"encryption_https": "HTTPS-Port",
|
||||
"encryption_key": "Privater Schlüssel",
|
||||
"encryption_key_input": "Kopieren Sie Ihren PEM-codierten privaten Schlüssel für Ihr Zertifikat und fügen Sie ihn hier ein.",
|
||||
"encryption_key_source_content": "Inhalt des privaten Schlüssels einfügen",
|
||||
"encryption_plain_dns": "Einfaches DNS",
|
||||
"encryption_plain_dns_error": "Um einfaches DNS zu deaktivieren, aktivieren Sie mindestens ein verschlüsseltes DNS-Protokoll",
|
||||
"encryption_private_key_path": "Pfad des privaten Schlüssels",
|
||||
"encryption_server": "Servername",
|
||||
"encryption_server_enter": "Domain-Namen eingeben",
|
||||
"encryption_title": "Verschlüsselung",
|
||||
"enter_cache_size": "Größe des Cache (Bytes) eingeben",
|
||||
"enter_ip_address_placeholder": "IP-Adresse eingeben",
|
||||
"error": "Fehler",
|
||||
"fallback_dns": "Fallback-DNS-Server",
|
||||
"filter_category_general": "Allgemein",
|
||||
"filter_category_general_desc": "Listen, die Tracking und Werbung auf den meisten Geräten sperren",
|
||||
"filter_category_other": "Sonstiges",
|
||||
"filter_category_other_desc": "Weitere Sperrlisten",
|
||||
"filter_category_regional": "Regional",
|
||||
"filter_category_regional_desc": "Listen, die sich auf regionale Werbeanzeigen und Tracking-Server konzentrieren",
|
||||
"filter_category_security": "Sicherheit",
|
||||
"filter_category_security_desc": "Listen, die auf das Sperren von Malware-, Phishing- oder Scam-Domains spezialisiert sind",
|
||||
"filter_removed_successfully": "Der Filter wurde erfolgreich entfernt",
|
||||
"filters": "Filter",
|
||||
"forgot_password": "Passwort vergessen?",
|
||||
"form_enter_blocked_response_ttl": "Geben Sie die Gültigkeitsdauer für blockierte Antworten ein (in Sekunden)",
|
||||
"form_error_answer_format": "Ungültiges Antwortformat",
|
||||
"form_error_client_id_format": "Client-ID muss nur Zahlen, Kleinbuchstaben und Bindestriche enthalten",
|
||||
"form_error_domain_format": "Ungültiges Domainformat",
|
||||
"form_error_equal": "Sollten nicht übereinstimmen",
|
||||
"form_error_gateway_ip": "Lease kann nicht die IP-Adresse des Gateways haben",
|
||||
"form_error_ip4_format": "Ungültige IPv4-Adresse",
|
||||
"form_error_ip6_format": "Ungültige IPv6-Adresse",
|
||||
"form_error_ip_format": "Ungültige IP-Adresse",
|
||||
"form_error_mac_format": "Ungültige MAC-Adresse",
|
||||
"form_error_password": "Passwörter stimmen nicht überein",
|
||||
"form_error_port": "Geben Sie eine gültige Portnummer ein",
|
||||
"form_error_port_range": "Geben Sie die Portnummer zwischen 80 und 65535 ein",
|
||||
"form_error_port_unsafe": "Unsicherer Port",
|
||||
"form_error_required": "Bitte dieses Feld ausfüllen",
|
||||
"form_error_server_name": "Ungültiger Servername",
|
||||
"form_error_url_or_path_format": "Ungültige URL oder absoluter Pfad der Liste",
|
||||
"friday": "Freitag",
|
||||
"gateway_or_subnet_invalid": "Ungültige Subnetzmaske",
|
||||
"general_settings": "Allgemeine Einstellungen",
|
||||
"general_statistics": "Allgemeine Statistiken",
|
||||
"greater_range_start_error": "Muss größer als der Bereichsbeginn sein",
|
||||
"ignore_domains_desc_query": "Abfragen, die diesen Regeln entsprechen, werden nicht in das Abfrageprotokoll aufgenommen",
|
||||
"ignore_domains_title": "Ignorierte Domains",
|
||||
"inactivity_schedule_add": "Zeitplan hinzufügen",
|
||||
"inactivity_schedule_edit": "Zeitplan bearbeiten",
|
||||
"install_auth_confirm": "Passwort bestätigen",
|
||||
"install_auth_password": "Passwort",
|
||||
"install_auth_password_enter": "Passwort eingeben",
|
||||
"install_auth_username": "Benutzername",
|
||||
"install_auth_username_enter": "Benutzernamen eingeben",
|
||||
"install_saved": "Erfolgreich gespeichert",
|
||||
"install_settings_all_interfaces": "Alle Schnittstellen",
|
||||
"install_settings_port": "Port",
|
||||
"install_static_error": "AdGuard Home kann nicht automatisch für diese Netzwerkschnittstelle konfiguriert werden. Bitte suchen Sie nach einer Anleitung, wie Sie dies manuell durchführen können.",
|
||||
"install_static_ok": "Gute Nachrichten! Die feste IP-Adresse ist bereits konfiguriert",
|
||||
"install_step": "Schritt",
|
||||
"install_submit_title": "Gratulation!",
|
||||
"ip_address": "IP-Adresse",
|
||||
"known_tracker": "Bekannte Tracker",
|
||||
"light_theme": "Helles Design",
|
||||
"login": "Anmelden",
|
||||
"logout": "Abmelden",
|
||||
"make_static": "Statisch machen",
|
||||
"milliseconds_abbreviation": "ms",
|
||||
"monday": "Montag",
|
||||
"name": "Name",
|
||||
"name_label": "Name",
|
||||
"name_table_header": "Name",
|
||||
"name_tooltip": "Name",
|
||||
"next": "Weiter",
|
||||
"no": "Nein",
|
||||
"nothing_found": "Nichts gefunden",
|
||||
"notify_undo": "Rückgängig",
|
||||
"notify_updated": "Aktualisiert",
|
||||
"parental_control": "Kindersicherung",
|
||||
"parental_group_cdn": "CDN",
|
||||
"parental_group_development": "Softwareentwicklung",
|
||||
"parental_group_gaming": "Spiele",
|
||||
"parental_group_hosting": "Webhosting",
|
||||
"parental_group_privacy": "Datenschutz-Tools",
|
||||
"parental_group_shopping": "Einkaufen",
|
||||
"parental_group_social": "Soziale Netzwerke",
|
||||
"parental_group_social_network": "Soziale Netzwerke",
|
||||
"parental_group_software": "Softwareentwicklung",
|
||||
"parental_group_streaming": "Streaming",
|
||||
"password_label": "Passwort",
|
||||
"password_placeholder": "Passwort eingeben",
|
||||
"plain_dns": "Einfaches DNS",
|
||||
"privacy_policy": "Datenschutzerklärung",
|
||||
"protection": "Schutz",
|
||||
"protocol": "Protokoll",
|
||||
"query_log_allowed": "Auf Freigabeliste",
|
||||
"query_log_blocked": "Blockiert",
|
||||
"query_log_blocked_services": "Gesperrte Dienste",
|
||||
"query_log_blocked_threats": "Gesperrte Bedrohungen",
|
||||
"query_log_custom_filtering_rules": "Benutzerdefinierte Filterregeln",
|
||||
"query_log_processed": "Verarbeitet",
|
||||
"query_log_retention": "Rotation der Abfrageprotokolle",
|
||||
"query_log_rewritten": "Umgeschrieben",
|
||||
"query_log_safe_search": "Sichere Suche",
|
||||
"query_log_strict_search": "Doppelte Anführungszeichen für die strikte Suche verwenden",
|
||||
"rate_limit_subnet_len_ipv4_error": "Die Subnetzpräfixlänge für IPv4-Adressen sollte zwischen 0 und 32 liegen",
|
||||
"rate_limit_subnet_len_ipv6_error": "Die Subnetzpräfixlänge für IPv6-Adressen sollte zwischen 0 und 128 liegen",
|
||||
"refresh_btn": "Aktualisieren",
|
||||
"refresh_statics": "Statistiken aktualisieren",
|
||||
"release_notes": "Versionshinweise",
|
||||
"remove": "Entfernen",
|
||||
"report_an_issue": "Diese Webseite melden",
|
||||
"request_table_header": "Anfrage",
|
||||
"requests_table_header": "Anfragen",
|
||||
"reset": "Zurücksetzen",
|
||||
"reset_settings": "Einstellungen zurücksetzen",
|
||||
"reset_settings_confirm": "Ja, zurücksetzen",
|
||||
"response_details": "Einzelheiten der Antwort",
|
||||
"response_time": "Antwortzeit",
|
||||
"result": "Status",
|
||||
"rewrite_add": "DNS-Umschreibung hinzufügen",
|
||||
"rewrite_edit": "DNS-Rewrite bearbeiten",
|
||||
"rewrite_hosts_applied": "Von Hostdatei-Regel umgeschrieben",
|
||||
"rewrite_settings_updated": "DNS-Umschreibungs-Einstellungen aktualisiert",
|
||||
"rewrite_updated": "DNS-Rewrite erfolgreich aktualisiert",
|
||||
"rewritten": "Umgeschrieben",
|
||||
"rules_label": "Regeln",
|
||||
"safe_browsing": "Internetsicherheit",
|
||||
"safe_search": "Sichere Suche",
|
||||
"saturday": "Samstag",
|
||||
"save": "Speichern",
|
||||
"save_btn": "Speichern",
|
||||
"search_placeholder": "Suchen",
|
||||
"server_config_blocking_mode": "Sperrmodus",
|
||||
"server_config_blocking_mode_ipv4": "IPv4-Sperren",
|
||||
"server_config_blocking_mode_ipv6": "IPv6-Sperren",
|
||||
"server_config_custom_ip": "Benutzerdefinierte IP-Adresse",
|
||||
"server_config_default": "Standard",
|
||||
"server_config_disable_ipv6": "IPv6 deaktivieren",
|
||||
"server_config_dnssec_enable": "DNSSEC aktivieren",
|
||||
"server_config_edns_enable": "EDNS-Client-Subnetz aktivieren",
|
||||
"server_config_nxdomain": "NXDomain",
|
||||
"server_config_rate_limit_placeholder": "Begrenzungswert eingeben",
|
||||
"server_config_rate_limit_whitelist_faq": "IP-Adressen, die von der Ratebegrenzung ausgeschlossen sind",
|
||||
"server_config_refused": "REFUSED",
|
||||
"server_config_subnet_len_ipv4": "Länge des Subnetzpräfixes für IPv4-Adressen",
|
||||
"server_config_subnet_len_ipv4_faq": "Subnetpräfixlänge für IPv4-Adressen, die für die Ratebegrenzung verwendet werden. Der Standardwert ist 24",
|
||||
"server_config_subnet_len_ipv6": "Subnetzpräfixlänge für IPv6-Adressen",
|
||||
"server_config_subnet_len_ipv6_faq": "Subnetpräfixlänge für IPv6-Adressen, die für die Ratebegrenzung verwendet werden. Der Standardwert ist 56",
|
||||
"set_static_ip": "Feste IP-Adresse festlegen",
|
||||
"settings": "Einstellungen",
|
||||
"settings_browsing_security": "Internetsicherheit",
|
||||
"settings_confirm_clear_statistics": "Statistiken leeren?",
|
||||
"settings_custom": "Benutzerdefiniert",
|
||||
"settings_general_short": "Allgemein",
|
||||
"settings_global": "Allgemein",
|
||||
"settings_notify_changes_saved": "Änderungen gespeichert",
|
||||
"settings_notify_statistics_cleared": "Statistiken gelöscht",
|
||||
"settings_parental_control": "Kindersicherung",
|
||||
"settings_rule_syntax": "Regelsyntax",
|
||||
"settings_safe_search": "Sichere Suche",
|
||||
"settings_statistics": "Statistiken",
|
||||
"settings_statistics_clear": "Statistiken zurücksetzen",
|
||||
"settings_statistics_retention": "Statistiken speichern",
|
||||
"settings_yes_clear": "Ja, bitte",
|
||||
"setup_devices_router_title": "Router",
|
||||
"setup_dns_privacy_ioc_mac": "Konfiguration für iOS und macOS",
|
||||
"setup_dns_privacy_other_title": "Weitere Implementierungen",
|
||||
"show_blocked_responses": "Blockiert",
|
||||
"show_processed_responses": "Verarbeitet",
|
||||
"source_label": "Quelle",
|
||||
"source_tooltip": "Quelle",
|
||||
"stats_adult": "Gesperrte jugendgefährdende Websites",
|
||||
"stats_query_domain": "Am häufigsten angefragte Domains",
|
||||
"status_table_header": "Status",
|
||||
"subnet_error": "Die Adressen müssen innerhalb eines Subnetzes liegen",
|
||||
"sunday": "Sonntag",
|
||||
"system_host_files": "Hosts-Datei des Systems",
|
||||
"table_client": "Client",
|
||||
"tags_title": "Schlagwörter",
|
||||
"test_upstreams": "Upstreams testen",
|
||||
"threats_blocked": "Gesperrte Bedrohungen",
|
||||
"thursday": "Donnerstag",
|
||||
"time_table_header": "Zeit",
|
||||
"top_blocked_domains": "Am häufigsten gesperrte Domains",
|
||||
"top_clients": "Top Clients",
|
||||
"top_upstreams": "Top Upstreams",
|
||||
"total": "Insgesamt",
|
||||
"try_again": "Erneut versuchen",
|
||||
"tuesday": "Dienstag",
|
||||
"unavailable_dhcp": "DHCP ist nicht verfügbar",
|
||||
"unavailable_dhcp_desc": "AdGuard Home konnte keinen DHCP-Server auf Ihrem Betriebssystem ausführen",
|
||||
"unblock": "Entsperren",
|
||||
"update_filters_disable": "Deaktivieren",
|
||||
"update_filters_interval_custom": "Benutzerdefiniert",
|
||||
"update_filters_interval_daily": "Täglich",
|
||||
"update_filters_interval_hourly": "Jede Stunde",
|
||||
"update_filters_title": "Filter automatisch aktualisieren",
|
||||
"updated_custom_filtering_toast": "Benutzerdefinierten Filterregeln erfolgreich gespeichert",
|
||||
"updated_upstream_dns_toast": "Upstream-Server erfolgreich gespeichert",
|
||||
"updates_checked": "Neue Version von AdGuard Home ist jetzt verfügbar",
|
||||
"updates_version_equal": "AdGuard Home ist aktuell",
|
||||
"upstream": "Upstream",
|
||||
"upstream_dns": "Upstream-DNS-Server",
|
||||
"upstream_dns_addresses": "Serveradressen (eine pro Zeile)",
|
||||
"upstream_dns_fastest_addr": "Schnellste IP-Adresse",
|
||||
"upstream_dns_load_balancing": "Lastverteilung",
|
||||
"upstream_dns_parallel_requests": "Paralleles Abfragen",
|
||||
"upstream_dns_servers_title": "Upstream-DNS-Server",
|
||||
"upstream_examples_title": "Beispiele",
|
||||
"upstream_private_ptr_resolvers_title": "Private Reverse-DNS-Resolver verwenden",
|
||||
"upstreams": "Upstreams",
|
||||
"url_label": "URL",
|
||||
"use_saved_key": "Zuvor gespeicherten Schlüssel verwenden",
|
||||
"user_rules": "Benutzerregeln",
|
||||
"user_rules_add_to_allowlist": "Zur Positivliste hinzufügen",
|
||||
"user_rules_check_button": "Prüfen",
|
||||
"user_rules_check_client_placeholder": "Client-Kennung eingeben",
|
||||
"user_rules_check_hostname_label": "Hostname oder Domainname",
|
||||
"user_rules_disable_filter": "Filter deaktivieren",
|
||||
"user_rules_disable_parental_control": "Kindersicherung deaktivieren",
|
||||
"user_rules_dns_record_type_placeholder": "DNS-Datensatztyp auswählen",
|
||||
"user_rules_domain_blocked": "Domain ist gesperrt",
|
||||
"user_rules_edit_dns_rewrite": "DNS-Rewrite bearbeiten",
|
||||
"user_rules_rewrite_rule_is_applied": "Umschreibungsregel ist angewendet",
|
||||
"user_rules_title": "Benutzerdefinierte Filterregeln",
|
||||
"username_label": "Benutzername",
|
||||
"username_placeholder": "Benutzernamen eingeben",
|
||||
"validated_with_dnssec": "Bestätigt mit DNSSEC",
|
||||
"wednesday": "Mittwoch",
|
||||
"whois": "Whois",
|
||||
"yes": "Ja"
|
||||
}
|
||||
@@ -0,0 +1,844 @@
|
||||
{
|
||||
"language": "Language",
|
||||
"client_settings": "Client settings",
|
||||
"bootstrap_dns": "Bootstrap DNS servers",
|
||||
"fallback_dns": "Fallback DNS servers",
|
||||
"form_error_ip4_format": "Invalid IPv4 address",
|
||||
"form_error_ip6_format": "Invalid IPv6 address",
|
||||
"form_error_ip_format": "Invalid IP address",
|
||||
"form_error_mac_format": "Invalid MAC address",
|
||||
"form_error_client_id_format": "ClientID must contain only numbers, lowercase letters, and hyphens",
|
||||
"form_error_server_name": "Invalid server name",
|
||||
"form_error_subnet": "Subnet \"%cidr%\" does not contain the IP address \"%ip%\"",
|
||||
"form_error_gateway_ip": "Lease can't have the IP address of the gateway",
|
||||
"out_of_range_error": "Must be out of range \"%start%\"-\"%end%\"",
|
||||
"greater_range_start_error": "Must be greater than range start",
|
||||
"subnet_error": "Addresses must be in one subnet",
|
||||
"gateway_or_subnet_invalid": "Invalid subnet mask",
|
||||
"response_details": "Response details",
|
||||
"client_details": "Client details",
|
||||
"back": "Back",
|
||||
"dashboard": "Dashboard",
|
||||
"settings": "Settings",
|
||||
"filters": "Filters",
|
||||
"nothing_found": "Nothing found",
|
||||
"protocol": "Protocol",
|
||||
"report_an_issue": "Report a problem",
|
||||
"refresh_statics": "Refresh statistics",
|
||||
"dns_query": "DNS queries",
|
||||
"stats_adult": "Blocked adult websites",
|
||||
"stats_query_domain": "Top queried domains",
|
||||
"top_blocked_domains": "Top blocked domains",
|
||||
"top_clients": "Top clients",
|
||||
"general_statistics": "General statistics",
|
||||
"top_upstreams": "Top upstreams",
|
||||
"average_upstream_response_time": "Average upstream response time",
|
||||
"response_time": "Response time",
|
||||
"custom_filtering_rules": "Custom filtering rules",
|
||||
"upstream_dns_configured_in_file": "Configured in %path%",
|
||||
"upstreams": "Upstreams",
|
||||
"upstream": "Upstream",
|
||||
"name_table_header": "Name",
|
||||
"request_table_header": "Request",
|
||||
"add_blocklist": "Add blocklist",
|
||||
"add_allowlist": "Add allowlist",
|
||||
"check_updates_btn": "Check for updates",
|
||||
"form_error_url_format": "Invalid URL",
|
||||
"form_error_upstream_format": "Invalid upstream format on line %line%",
|
||||
"form_error_upstream_format_multi": "Invalid upstream format on lines %lines%",
|
||||
"custom_filter_rules": "Custom filtering rules",
|
||||
"system_host_files": "System hosts files",
|
||||
"all_lists_up_to_date_toast": "All lists are already up-to-date",
|
||||
"updated_upstream_dns_toast": "Upstream servers saved",
|
||||
"dns_server_configuration_saved_toast": "DNS server configuration saved",
|
||||
"dns_cache_configuration_saved_toast": "DNS cache configuration saved",
|
||||
"access_settings_saved_toast": "Access settings saved",
|
||||
"dns_test_ok_toast": "Specified DNS servers are working correctly",
|
||||
"dns_test_not_ok_toast": "Server \"%key%\": could not be used, please check that you've written it correctly",
|
||||
"dns_test_parsing_error_toast": "Section %section%: line %line%: could not be used, please check that you've written it correctly",
|
||||
"dns_test_warning_toast": "Upstream \"%key%\" does not respond to test requests and may not work properly",
|
||||
"unblock": "Unblock",
|
||||
"block": "Block",
|
||||
"disallow_this_client": "Disallow this client",
|
||||
"block_for_this_client_only": "Block for this client only",
|
||||
"add_persistent_client": "Add as persistent client",
|
||||
"time_table_header": "Time",
|
||||
"client_table_header": "Client",
|
||||
"query_log_retention": "Log rotation",
|
||||
"dns_config": "DNS server configuration",
|
||||
"dns_cache_config": "DNS cache configuration",
|
||||
"form_enter_blocked_response_ttl": "Enter blocked response TTL (seconds)",
|
||||
"dns_over_https": "DNS-over-HTTPS",
|
||||
"dns_over_tls": "DNS-over-TLS",
|
||||
"dns_over_quic": "DNS-over-QUIC",
|
||||
"client_id": "ClientID",
|
||||
"client_id_placeholder": "Enter a ClientID",
|
||||
"client_added": "Client added",
|
||||
"client_removed": "Client removed",
|
||||
"client_name_already_exists": "Client with this name already exists",
|
||||
"download_mobileconfig": "Download configuration file",
|
||||
"plain_dns": "Plain DNS",
|
||||
"rate_limit_subnet_len_ipv4_error": "The IPv4 subnet prefix length should be between 0 and 32",
|
||||
"rate_limit_subnet_len_ipv6_error": "The IPv6 subnet prefix length should be between 0 and 128",
|
||||
"source_label": "Source",
|
||||
"unknown_filter": "Unknown filter %filterId%",
|
||||
"known_tracker": "Known tracker",
|
||||
"install_settings_port": "Port",
|
||||
"form_error_port": "Enter valid port number",
|
||||
"install_settings_all_interfaces": "All interfaces",
|
||||
"install_auth_username": "Username",
|
||||
"install_auth_password": "Password",
|
||||
"install_auth_confirm": "Confirm password",
|
||||
"install_auth_username_enter": "Enter username",
|
||||
"install_auth_password_enter": "Enter password",
|
||||
"install_step": "Step",
|
||||
"install_submit_title": "Congratulations!",
|
||||
"next": "Next",
|
||||
"open_dashboard": "Open dashboard",
|
||||
"install_saved": "Saved successfully",
|
||||
"encryption_config_saved": "Encryption configuration saved",
|
||||
"encryption_server_enter": "Enter your domain name",
|
||||
"encryption_certificates_input": "Copy/paste your PEM-encoded certificates here.",
|
||||
"encryption_key_input": "Copy/paste your PEM-encoded private key for your certificate here.",
|
||||
"encryption_plain_dns_error": "To disable plain DNS, enable at least one encrypted DNS protocol",
|
||||
"form_error_port_range": "Enter port number in the range of 80-65535",
|
||||
"form_error_port_unsafe": "Unsafe port",
|
||||
"form_error_equal": "Must not be equal",
|
||||
"form_error_password": "Password mismatch",
|
||||
"clients_title": "Persistent clients",
|
||||
"clients_desc": "Rules for devices connected to AdGuard Home",
|
||||
"settings_global": "Global",
|
||||
"table_client": "Client",
|
||||
"client_add": "Add Client",
|
||||
"client_identifier": "Identifier",
|
||||
"ip_address": "IP address",
|
||||
"name": "Name",
|
||||
"copy_to_clipboard": "Copy to clipboard",
|
||||
"clients_not_found": "No clients found",
|
||||
"auto_clients_title": "Runtime clients",
|
||||
"auto_clients_desc": "Information about the IP addresses of devices that can use AdGuard Home. This information is collected from multiple sources, including hosts files and reverse DNS",
|
||||
"updates_checked": "A new version of AdGuard Home is available",
|
||||
"updates_version_equal": "AdGuard Home is up-to-date",
|
||||
"dns_privacy": "DNS Privacy",
|
||||
"setup_dns_privacy_other_title": "Other implementations",
|
||||
"setup_dns_privacy_ioc_mac": "iOS and macOS configuration",
|
||||
"rewrite_updated": "DNS rewrite successfully updated",
|
||||
"rewrite_edit": "Edit DNS rewrite",
|
||||
"rewrite_hosts_applied": "Rewritten by the hosts file rule",
|
||||
"form_error_domain_format": "Invalid domain format",
|
||||
"form_error_answer_format": "Invalid result format",
|
||||
"encryption_certificate_path": "Certificate path",
|
||||
"encryption_private_key_path": "Private key path",
|
||||
"encryption_certificates_source_path": "Set a certificates file path",
|
||||
"encryption_certificates_source_content": "Paste the certificates contents",
|
||||
"encryption_key_source_path": "Set a private key file",
|
||||
"encryption_key_source_content": "Paste the private key contents",
|
||||
"filter_removed_successfully": "Removed blocklist: <strong>%value%</strong>",
|
||||
"filter_added_successfully": "Added blocklist: <strong>%value%</strong>",
|
||||
"filter_added_successfully_more": "Added blocklists: <strong>%value%</strong> and %more% more",
|
||||
"filter_removed_successfully_allowlist": "Removed allowlist: <strong>%value%</strong>",
|
||||
"filter_added_successfully_allowlist": "Added allowlist: <strong>%value%</strong>",
|
||||
"ignore_domains_title": "Ignored domains",
|
||||
"ignore_domains_desc_log": "Requests that match these rules are not displayed in Query log",
|
||||
"ignore_domains_desc_stats": "Requests that match these rules are not included in the statistics",
|
||||
"username_label": "Username",
|
||||
"username_placeholder": "Enter username",
|
||||
"password_label": "Password",
|
||||
"password_placeholder": "Enter password",
|
||||
"clear_btn": "Clear",
|
||||
"whois": "WHOIS",
|
||||
"blocked_by_cname_or_ip": "Blocked by CNAME or IP",
|
||||
"autofix_warning_text": "If you click \"Fix\", AdGuard Home will configure your system to use AdGuard Home DNS server.",
|
||||
"autofix_warning_list": "<p>It will perform these tasks:</p><p>Deactivate system DNSStubListener</p><p>Set DNS server address to 127.0.0.1</p><p>Replace symbolic link target of /etc/resolv.conf with /run/systemd/resolve/resolv.conf</p><p>Stop DNSStubListener (reload systemd-resolved service)</p>",
|
||||
"autofix_warning_result": "As a result all DNS requests from your system will be processed by AdGuard Home by default.",
|
||||
"tags_title": "Tags",
|
||||
"check_not_found": "Not found in your filter lists",
|
||||
"set_static_ip": "Set a static IP address",
|
||||
"install_static_ok": "Good news! The static IP address is already configured",
|
||||
"install_static_error": "AdGuard Home cannot configure it automatically for this network interface. Please look for an instruction on how to do this manually.",
|
||||
"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?",
|
||||
"confirm_static_ip": "AdGuard Home will configure %ip% to be your static IP address. Do you want to proceed?",
|
||||
"list_updated": "| %count% list updated | %count% lists updated",
|
||||
"validated_with_dnssec": "Validated with DNSSEC",
|
||||
"show_blocked_responses": "Blocked",
|
||||
"show_processed_responses": "Processed",
|
||||
"blocked_adult_websites": "Blocked by Parental control",
|
||||
"blocked_threats": "Blocked threats",
|
||||
"allowed": "Allowed",
|
||||
"rewritten": "Rewritten",
|
||||
"safe_search": "Safe search",
|
||||
"milliseconds_abbreviation": "ms",
|
||||
"processing_time_ms": "%value% ms",
|
||||
"enter_cache_size": "Enter cache size (bytes)",
|
||||
"filter_category_general": "General",
|
||||
"filter_category_security": "Security",
|
||||
"filter_category_regional": "Regional",
|
||||
"filter_category_other": "Other",
|
||||
"filter_category_general_desc": "Lists that block tracking and advertising on most of the devices",
|
||||
"filter_category_security_desc": "Lists designed specifically to block malicious, phishing, and scam domains",
|
||||
"filter_category_regional_desc": "Lists that focus on regional ads and tracking servers",
|
||||
"filter_category_other_desc": "Other blocklists",
|
||||
"port_53_faq_link": "Port 53 is often occupied by \"DNSStubListener\" or \"systemd-resolved\" services. Please read <a>this instruction</a> on how to resolve this.",
|
||||
"use_saved_key": "Use an existing private key",
|
||||
"parental_control": "Parental Control",
|
||||
"safe_browsing": "Safe Browsing",
|
||||
"cache_cleared": "DNS cache successfully cleared",
|
||||
"allowlists": "Allowlists",
|
||||
"user_rules": "User rules",
|
||||
"logout": "Log out",
|
||||
"system_theme": "System theme",
|
||||
"dark_theme": "Dark theme",
|
||||
"light_theme": "Light theme",
|
||||
"release_notes": "Release notes",
|
||||
"privacy_policy": "Privacy policy",
|
||||
"selected": "%value% selected",
|
||||
"settings_general_short": "General",
|
||||
"settings_filter_requests": "Filter requests",
|
||||
"settings_filtering_and_security": "Filtering and security",
|
||||
"settings_filter_requests_desc": "You can use <a>Blocklists</a>, <b>Allowlists</b>, and <c>User rules</c> to set up filtering rules",
|
||||
"settings_browsing_security": "Browsing security",
|
||||
"settings_browsing_security_desc": "Block access to domains found in the database of malicious and phishing domains",
|
||||
"settings_parental_control": "Parental control",
|
||||
"settings_parental_control_desc": "Block websites with content inappropriate for children",
|
||||
"settings_safe_search": "Safe search",
|
||||
"settings_safe_search_desc": "Block inappropriate content in search results",
|
||||
"settings_log_dns_requests": "Log DNS requests",
|
||||
"settings_anonymize_client_ip": "Anonymize client IP addresses",
|
||||
"settings_anonymize_client_ip_desc": "Replace part of the IP address with zeros",
|
||||
"settings_hours": "| %count% hour | %count% hours",
|
||||
"settings_days": "| %count% day | %count% days",
|
||||
"settings_custom": "Custom",
|
||||
"settings_log_rotation_hours": "Log rotation, in hours",
|
||||
"settings_rotation_placeholder": "Use numbers",
|
||||
"settings_domain_names": "Domain names, one per line",
|
||||
"settings_rule_syntax": "Rule syntax",
|
||||
"form_error_required": "Please fill out this field",
|
||||
"form_value_length_common_error": "This value should be less than or equal to %max_length%",
|
||||
"form_value_value_between_error": "This value should be between %min_value% and %max_value%",
|
||||
"form_value_value_min_error": "This value should be %min_value% or higher",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"clear_query_log": "Clear Query log",
|
||||
"settings_statistics": "Statistics",
|
||||
"settings_statistics_desc": "Display request and client statistics on the dashboard",
|
||||
"settings_statistics_retention": "Statistics retention",
|
||||
"settings_statistics_retention_hours": "Statistics retention, in hours",
|
||||
"settings_statistics_clear": "Clear statistics",
|
||||
"settings_confirm_decrease_log_rotation_interval": "Decrease log rotation interval?",
|
||||
"settings_confirm_decrease_log_rotation_interval_desc": "This will delete all logs older than %value%",
|
||||
"settings_yes_decrease": "Yes, decrease",
|
||||
"settings_confirm_decrease_stats_rotation_interval": "Decrease stats retention interval?",
|
||||
"settings_confirm_decrease_stats_rotation_interval_desc": "This will delete all stats older than %value%",
|
||||
"settings_confirm_clear_statistics": "Clear statistics?",
|
||||
"settings_confirm_clear_statistics_desc": "This will clear all statistics. <strong>Query log</strong> will still be available",
|
||||
"settings_yes_clear": "Yes, clear",
|
||||
"settings_confirm_clear_query_log": "Clear Query log?",
|
||||
"settings_confirm_clear_query_log_desc": "This will delete all requests from <strong>Query log</strong>. Statistics will still be available",
|
||||
"settings_tooltip_domain_names": "Domain names, wildcards, or filtering rules",
|
||||
"settings_tooltip_examples": "Examples:",
|
||||
"settings_notify_changes_saved": "Changes saved",
|
||||
"settings_notify_query_log_cleared": "Query log cleared",
|
||||
"settings_notify_statistics_cleared": "Statistics cleared",
|
||||
"encryption_title": "Encryption",
|
||||
"encryption_encrypted_dns": "Encrypted DNS",
|
||||
"encryption_encrypted_dns_desc": "The AdGuard Home admin interface uses HTTPS. The DNS server supports DNS-over-HTTPS, DNS-over-TLS, and DNS-over-QUIC",
|
||||
"encryption_plain_dns": "Plain DNS",
|
||||
"encryption_plain_dns_desc": "Plain DNS is enabled by default. You can disable it to force all devices to use encrypted DNS. To do this, you must specify at least one encrypted DNS protocol",
|
||||
"encryption_server": "Server name",
|
||||
"encryption_server_tooltip_1": "If set, AdGuard Home will detect ClientIDs, respond to DDR queries, and perform additional connection validations. If not set, these features are disabled",
|
||||
"encryption_server_tooltip_2": "Must match a DNS name in the certificate",
|
||||
"encryption_https": "HTTPS port",
|
||||
"encryption_https_tooltip": "This port provides HTTPS access for the AdGuard Home admin interface. It also provides DNS-over-HTTPS at the '/dns-query' location",
|
||||
"encryption_dot": "DNS-over-TLS port",
|
||||
"encryption_dot_tooltip": "This port is used to run a DNS-over-TLS server",
|
||||
"encryption_doq": "DNS-over-QUIC port",
|
||||
"encryption_doq_tooltip": "This port is used to run a DNS-over-QUIC server",
|
||||
"encryption_force_redirect": "Automatically redirect from HTTP to HTTPS",
|
||||
"encryption_certificates": "Certificates",
|
||||
"encryption_certificates_desc": "To use encryption, you must provide a valid SSL certificate chain for your domain. You can get a free SSL certificate from <a>letsencrypt.org</a> or buy one from a trusted Certificate Authority",
|
||||
"encryption_key": "Private key",
|
||||
"encryption_disable_plain_dns": "Disable plain DNS?",
|
||||
"encryption_disable_plain_dns_desc": "Devices that do not support encrypted DNS may not work properly",
|
||||
"disable": "Disable",
|
||||
"disable_protection_btn": "Disable protection",
|
||||
"encryption_chain_valid": "Certificate chain is valid",
|
||||
"encryption_chain_invalid": "Certificate chain is invalid",
|
||||
"encryption_key_valid": "Private key is valid",
|
||||
"encryption_key_type": "Encryption algorithm: %value%",
|
||||
"encryption_key_invalid": "Private key is invalid",
|
||||
"encryption_subject": "Subject: %value%",
|
||||
"encryption_issuer": "Issuer: %value%",
|
||||
"encryption_expire": "Expires: %value%",
|
||||
"encryption_hostnames": "Hostnames: %value%",
|
||||
"encryption_certificate_has_issues": "Certificate has issues",
|
||||
"encryption_confirm_clear": "Reset encryption settings",
|
||||
"encryption_confirm_clear_desc": "All encryption settings will be reset to their default values",
|
||||
"reset": "Reset",
|
||||
"confirm": "Confirm",
|
||||
"dns_settings": "DNS settings",
|
||||
"upstream_dns": "Upstream DNS servers",
|
||||
"upstream_dns_servers_title": "Upstream DNS servers",
|
||||
"upstream_dns_placeholder": "IP addresses, sdns://, quic://, https://, h3://, or tls://",
|
||||
"upstream_dns_addresses": "Server addresses, one per line",
|
||||
"upstream_dns_addresses_faq": "<a>Learn more</a> about configuring upstream DNS servers. Here is a <b>list of known DNS providers</b> to choose from.",
|
||||
"upstream_dns_load_balancing": "Load-balancing",
|
||||
"upstream_dns_load_balancing_desc": "Query one upstream server at a time AdGuard Home uses a weighted random algorithm to select servers with the lowest number of failed lookups and the lowest average lookup time",
|
||||
"upstream_dns_parallel_requests": "Parallel requests",
|
||||
"upstream_dns_parallel_requests_desc": "Use parallel queries to speed up resolving by querying all upstream servers simultaneously",
|
||||
"upstream_dns_fastest_addr": "Fastest IP address",
|
||||
"upstream_dns_fastest_addr_desc": "Wait for responses from <strong>all</strong> DNS servers, measure the TCP connection speed for each server, and return the IP address of the server with the fastest connection speed",
|
||||
"upstream_dns_fastest_addr_warning": "This mode can significantly slow down DNS queries, if one or more upstream servers are not responding. Make sure that your upstream servers are stable and your upstream timeout is low",
|
||||
"upstream_examples_title": "Examples",
|
||||
"upstream_example_udp": "<code>%ipv4%</code>,<code>%ipv6%</code>: regular DNS (over UDP)",
|
||||
"upstream_example_udp_port": "<code>%ipv4%</code>,<code>%ipv6%</code>: regular DNS (over UDP, with port)",
|
||||
"upstream_example_udp_hostname": "<code>%value%</code>: regular DNS (over UDP, hostname)",
|
||||
"upstream_example_tcp": "<code>%ipv4%</code>,<code>%ipv6%</code>: regular DNS (over TCP)",
|
||||
"upstream_example_tcp_port": "<code>%ipv4%</code>,<code>%ipv6%</code>: regular DNS (over TCP, with port)",
|
||||
"upstream_example_tcp_hostname": "<code>%value%</code>: regular DNS (over TCP, hostname)",
|
||||
"upstream_example_upstream_dot": "<code>%value%</code>: encrypted <a>DNS-over-TLS</a>",
|
||||
"upstream_example_upstream_doh": "<code>%value%</code>: encrypted <a>DNS-over-HTTPS</a>",
|
||||
"upstream_example_upstream_doh3": "<code>%value%</code>: encrypted DNS-over-HTTPS with forced <strong>HTTP/3</strong> and no fallback to HTTP/2",
|
||||
"upstream_example_upstream_doq": "<code>%value%</code>: encrypted <a>DNS-over-QUIC</a>",
|
||||
"upstream_example_upstream_sdns": "<code>%value%</code>: <a>DNS Stamps</a> for <b>DNSCrypt</b> or <c>DNS-over-HTTPS</c> resolvers",
|
||||
"upstream_example_upstream_reserved": "<code>%value%</code>: an upstream <a>for specific domains</a>",
|
||||
"upstream_example_multiple_upstreams_reserved": "<code>%value%</code>: multiple upstreams <a>for specific domains</a>",
|
||||
"upstream_example_upstream_comment": "<code>%value%</code>: a comment.",
|
||||
"upstream_fallback_title": "Fallback DNS servers, one per line",
|
||||
"upstream_fallback_title_faq": "Fallback DNS servers to use when upstream DNS servers are unavailable. Use the same syntax as for the upstream DNS servers",
|
||||
"ip_addresses_placeholder": "Enter IP addresses, one per line",
|
||||
"upstream_bootstrap_dns_title": "Bootstrap DNS servers, one per line",
|
||||
"upstream_bootstrap_dns_faq": "IP addresses of DNS servers used to resolve the IP addresses of DoH/DoT upstream resolvers. Comments are not allowed",
|
||||
"upstream_ptr": "Private reverse DNS servers, one per line",
|
||||
"upstream_ptr_faq_1": "AdGuard Home uses these DNS servers for private PTR, SOA, and NS requests. Private requests come from clients with a private IP address and ask for an ARPA domain that contains a subnet from a private IP address range, such as %value%",
|
||||
"upstream_ptr_faq_2": "If this field is empty, the default DNS resolvers of your OS will be used, except for the AdGuard Home IP addresses",
|
||||
"upstream_ptr_faq_3": "By default, AdGuard Home uses the following reverse DNS resolvers: %value_1% and %value_2%",
|
||||
"upstream_private_ptr_resolvers_title": "Use private reverse DNS resolvers",
|
||||
"upstream_private_ptr_resolvers_desc": "Resolve PTR, SOA, and NS requests for ARPA domains containing private IP addresses through private upstream servers, DHCP, /etc/hosts, and so on. If disabled, AdGuard Home will respond to all such requests with NXDOMAIN",
|
||||
"upstream_enable_reverse_lookup_title": "Enable reverse lookup of client IP addresses",
|
||||
"upstream_enable_reverse_lookup_desc": "Reverse-resolve client IP addresses to their hostnames by sending PTR queries to appropriate resolvers: private DNS servers for local clients, or upstream servers for clients with public IP addresses",
|
||||
"upstream_timeout": "Upstream timeout, in seconds",
|
||||
"upstream_timeout_faq": "Time to wait for a response from the upstream server",
|
||||
"upstream_timeout_placeholder": "Enter the upstream server timeout duration",
|
||||
"apply": "Apply",
|
||||
"test_upstreams": "Test upstreams",
|
||||
"server_config_rate_limit": "Rate limit, RPS",
|
||||
"server_config_rate_limit_faq": "The number of requests per second allowed per client. A value of 0 means no limit",
|
||||
"server_config_rate_limit_placeholder": "Enter rate limit",
|
||||
"server_config_subnet_len_ipv4": "Subnet prefix length for IPv4 addresses",
|
||||
"server_config_subnet_len_ipv4_faq": "Subnet prefix length for IPv4 addresses used for rate limiting. The default is 24",
|
||||
"server_config_subnet_len_ipv6": "Subnet prefix length for IPv6 addresses",
|
||||
"server_config_subnet_len_ipv6_faq": "Subnet prefix length for IPv6 addresses used for rate limiting. The default is 56",
|
||||
"server_config_subnet_len_placeholder": "Enter prefix length",
|
||||
"server_config_rate_limit_whitelist": "Rate-limiting allowlist",
|
||||
"server_config_rate_limit_whitelist_faq": "IP addresses excluded from rate limiting",
|
||||
"server_config_edns_enable": "Enable EDNS client subnet",
|
||||
"server_config_edns_cs_desc": "Add the EDNS Client Subnet (ECS) option to upstream requests and log the values sent by the clients in the query log",
|
||||
"server_config_edns_use_custom_ip": "Use custom IP address for EDNS",
|
||||
"server_config_edns_use_custom_ip_desc": "Replace the client IP address with the custom IP address below",
|
||||
"enter_ip_address_placeholder": "Enter IP address",
|
||||
"server_config_dnssec_enable": "Enable DNSSEC",
|
||||
"server_config_dnssec_enable_desc": "Set the DNSSEC flag in outbound DNS queries and check the result. Requires a DNSSEC-enabled resolver",
|
||||
"server_config_disable_ipv6": "Disable resolving of IPv6 addresses",
|
||||
"server_config_disable_ipv6_desc": "Drop all DNS queries for IPv6 addresses (type AAAA) and remove IPv6 hints from HTTPS responses",
|
||||
"server_config_blocking_mode": "Blocking mode",
|
||||
"server_config_default": "Default",
|
||||
"server_config_blocking_mode_default_desc": "<strong>Default:</strong> Respond with 0.0.0.0 for A and :: for AAAA if blocked by Adblock-style rule. Respond with the IP address specified in the rule if blocked by /etc/hosts-style rule",
|
||||
"server_config_refused": "REFUSED",
|
||||
"server_config_blocking_mode_refused_desc": "<strong>REFUSED:</strong> Respond with REFUSED code",
|
||||
"server_config_nxdomain": "NXDOMAIN",
|
||||
"server_config_blocking_mode_nxdomain_desc": "<strong>NXDOMAIN:</strong> Respond with NXDOMAIN code",
|
||||
"server_config_null_ip": "Null IP address",
|
||||
"server_config_blocking_mode_null_ip_desc": "<strong>Null IP address:</strong> Respond with 0.0.0.0 for A and :: for AAAA",
|
||||
"server_config_custom_ip": "Custom IP address",
|
||||
"server_config_blocking_mode_custom_ip_desc": "<strong>Custom IP address:</strong> Respond with a manually set IP address",
|
||||
"server_config_blocking_mode_ipv4": "Blocking IPv4",
|
||||
"server_config_blocking_mode_ipv4_placeholder": "Enter IPv4 address",
|
||||
"server_config_blocking_mode_ipv4_faq": "IP address to return for a blocked A request",
|
||||
"server_config_blocking_mode_ipv6": "Blocking IPv6",
|
||||
"server_config_blocking_mode_ipv6_placeholder": "Enter IPv6 address",
|
||||
"server_config_blocking_mode_ipv6_faq": "IP address to return for a blocked AAAA request",
|
||||
"server_config_blocking_mode_ttl": "Blocked response TTL, in seconds",
|
||||
"server_config_blocking_mode_ttl_faq": "Specifies how long clients should cache a filtered response",
|
||||
"cache_config_size": "Cache size, in bytes",
|
||||
"cache_config_size_faq": "DNS cache size. To disable caching, leave empty",
|
||||
"cache_config_min_ttl": "Override minimum TTL, in seconds",
|
||||
"cache_config_min_ttl_faq": "The minimum time-to-live (TTL) value for entries in the DNS cache",
|
||||
"cache_config_min_ttl_placeholder": "Enter minimum TTL",
|
||||
"cache_config_max_ttl": "Override maximum TTL, in seconds",
|
||||
"cache_config_max_ttl_faq": "The maximum time-to-live (TTL) value for entries in the DNS cache",
|
||||
"cache_config_max_ttl_placeholder": "Enter maximum TTL",
|
||||
"cache_config_optimistic": "Optimistic caching",
|
||||
"cache_config_optimistic_desc": "AdGuard Home will respond from the cache even if the entries have expired and will try to update them",
|
||||
"cache_config_clear": "Clear cache",
|
||||
"cache_config_size_validation": "The cache size must be greater than zero when enabled",
|
||||
"cache_config_ttl_validation": "Minimum cache TTL override must be less than or equal to the maximum",
|
||||
"cache_enabled": "Enable cache",
|
||||
"cache_enabled_desc": "Store DNS responses locally",
|
||||
"cache_confirm_clear_title": "Clear DNS cache",
|
||||
"cache_confirm_clear_desc": "This will remove all cached DNS responses. Subsequent DNS queries may be slower until the cache is rebuilt",
|
||||
"access_settings_title": "Access settings",
|
||||
"access_settings_allowed_title": "Allowed clients, one per line",
|
||||
"access_settings_allowed_faq": "A list of CIDRs, IP addresses, or <a>ClientIDs</a>. If this field has entries, AdGuard Home will accept requests only from these clients",
|
||||
"access_settings_disallowed_title": "Disallowed clients, one per line",
|
||||
"access_settings_disallowed_faq": "A list of CIDRs, IP addresses, or <a>ClientIDs</a>. If this field has entries, AdGuard Home will drop requests from these clients. This field is ignored if there are entries in <strong>Allowed clients</strong>",
|
||||
"access_settings_blocked_title": "Disallowed domains, one per line",
|
||||
"access_settings_blocked_faq_1": "AdGuard Home will drop DNS queries matching these domains. Such queries don't appear in the query log. You can enter exact domain names, wildcards, or URL filter rules, such as example.org, *.example.org, or ||example.org^",
|
||||
"access_settings_blocked_faq_2": "Not to be confused with filters",
|
||||
"access_settings_allowed_placeholder": "Enter allowed clients",
|
||||
"access_settings_disallowed_placeholder": "Enter disallowed clients",
|
||||
"access_settings_blocked_placeholder": "Enter disallowed domains",
|
||||
"blocklists_title": "Blocklists",
|
||||
"blocklists_desc": "Domains from these lists are blocked",
|
||||
"blocklists_add": "Add blocklists",
|
||||
"blocklists_add_desc": "You can select blocklists from the list or add them manually",
|
||||
"blocklists_empty": "No blocklists yet — <button>add your first one</button>",
|
||||
"allowlists_desc": "Domains from DNS allowlists will be allowed even if they are in any of the blocklists. AdGuard Home understands basic adblock rules and hosts files syntax",
|
||||
"name_label": "Name",
|
||||
"url_label": "URL",
|
||||
"rules_label": "Rules",
|
||||
"last_updated_label": "Last updated",
|
||||
"rows_per_page": "Rows per page %value%",
|
||||
"update_filters_title": "Update filters automatically",
|
||||
"update_filters_desc": "Set the auto-update interval for filters connected via an external link",
|
||||
"update_filters_disable": "Disable",
|
||||
"update_filters_interval_hourly": "Every hour",
|
||||
"update_filters_interval_daily": "Every day",
|
||||
"update_filters_interval_weekly": "Every week",
|
||||
"update_filters_interval_custom": "Custom",
|
||||
"update_filters_custom_hours": "Custom interval, in hours",
|
||||
"blocklist_add_from_list": "Add from list",
|
||||
"blocklist_add_manual": "Add manually",
|
||||
"blocklist_homepage": "Homepage",
|
||||
"blocklist_contents": "Contents (.%value%)",
|
||||
"add": "Add",
|
||||
"blocklist_edit": "Edit blocklist",
|
||||
"blocklist_url_file_path": "URL or file path",
|
||||
"blocklist_remove": "Remove blocklist?",
|
||||
"blocklist_remove_desc": "<strong>%value%</strong> will be removed",
|
||||
"remove": "Remove",
|
||||
"changes_saved_success": "Changes saved",
|
||||
"blocklist_placeholder_example": "My blocklist",
|
||||
"allowlists_title": "Allowlists",
|
||||
"allowlist_remove": "Remove allowlist?",
|
||||
"allowlist_remove_desc": "<strong>%value%</strong> will be removed",
|
||||
"allowlist_placeholder_example": "My allowlist",
|
||||
"allowlist_add": "Add allowlist",
|
||||
"allowlist_edit": "Edit allowlist",
|
||||
"allowlist_empty": "No allowlists yet — <button>add your first one</button>",
|
||||
"form_error_range": "Value must be between %min% and %max%",
|
||||
"dns_rewrites": "DNS rewrites",
|
||||
"dns_rewrites_desc": "Helps you configure custom DNS responses for specific domain names",
|
||||
"rewrite_add": "Add DNS rewrite",
|
||||
"rewrite_domain": "Domain name or wildcard to be rewritten",
|
||||
"rewrites_empty": "No rewrites yet — <button>add your first one</button>",
|
||||
"domain": "Domain",
|
||||
"result": "Result",
|
||||
"disable_dns_rewrites": "Disable DNS rewrites",
|
||||
"rewrite_domain_input_placeholder": "Enter the domain name or wildcard",
|
||||
"instructions": "Instructions",
|
||||
"rewrites_answer_input_placeholder": "Enter IP address, CNAME record, A, or AAAA",
|
||||
"rewrites_remove_title": "Remove DNS rewrite?",
|
||||
"rewrites_remove_desc": "The DNS rewrite for <strong>%value%</strong> will be removed",
|
||||
"rewrites_tooltip_examples_item1": "Rewrite DNS responses for this domain name only",
|
||||
"rewrites_tooltip_examples_item2": "Rewrite DNS responses for all subdomains of example.org",
|
||||
"rewrites_tooltip_instructions_item1": "To resolve the domain name to an IP address, enter an IPv4 address for A records and an IPv6 address for AAAA records",
|
||||
"rewrites_tooltip_instructions_item2": "To redirect the domain name, enter the CNAME record of the target domain",
|
||||
"rewrites_tooltip_instructions_item3": "To keep only A records, enter",
|
||||
"rewrites_tooltip_instructions_item4": "To keep only AАAA records, enter",
|
||||
"rewrite_settings_updated": "DNS rewrite settings successfully updated",
|
||||
"all_rewrites_disabled": "All custom DNS responses will be disabled",
|
||||
"enable_dns_rewrites": "Enable DNS rewrites",
|
||||
"all_rewrites_enabled": "All custom DNS responses will be enabled",
|
||||
"enable": "Enable",
|
||||
"sort_asc": "Sort from A-Z",
|
||||
"sort_desc": "Sort from Z-A",
|
||||
"all_domains": "All domains",
|
||||
"setup_guide_desc": "Configure your devices to use AdGuard Home",
|
||||
"setup_guide_device_type": "Select your device type",
|
||||
"home_dns_addresses": "AdGuard Home DNS addresses",
|
||||
"home_dns_addresses_desc": "The AdGuard Home DNS server listens on the following interfaces/ports:",
|
||||
"encrypted_dns_addresses": "Encrypted DNS server addresses",
|
||||
"plain_dns_addresses": "Plain DNS server addresses",
|
||||
"setup_devices_macos_list_1": "Click Apple menu → <i>System Preferences</i> → <i>Network</i>.",
|
||||
"setup_devices_macos_list_2": "Select the required connection and click <i>Advanced</i>.",
|
||||
"setup_devices_macos_list_3": "Select the <i>DNS</i> tab and enter the addresses of your AdGuard Home server.",
|
||||
"setup_devices_android_list_1": "Open your device's <i>Settings</i> app and select <i>Wi-Fi</i>.",
|
||||
"setup_devices_android_list_2": "Long press your current network, then select <i>Modify network</i>. It is not possible to set custom DNS for mobile Internet connections.",
|
||||
"setup_devices_android_list_3": "On some devices, you may need to select the <i>Advanced checkbox</i> to see more settings. To customize your Android DNS settings, you need to change IP settings from <i>DHCP</i> to <i>Static</i>.",
|
||||
"setup_devices_android_list_4": "Change the <i>DNS 1</i> and <i>DNS 2</i> values to the addresses of your AdGuard Home server.",
|
||||
"setup_devices_ios_list_1": "Open your device's <i>Settings</i> app and select <i>Wi-Fi</i>. It is not possible to set custom DNS for mobile Internet connections.",
|
||||
"setup_devices_ios_list_2": "Tap the name of the currently active network.",
|
||||
"setup_devices_ios_list_3": "Go to <i>DNS</i> → <i>Configure DNS</i> → <i>Manual</i> and add the addresses of your AdGuard Home server.",
|
||||
"setup_devices_ios_list_4": "Tap <i>Save</i>.",
|
||||
"setup_devices_router_desc_1": "Set up AdGuard Home on your home network router and protect all devices connected to it. No need to manually configure each device.",
|
||||
"setup_devices_router_list_1_title": "Open your router's settings.",
|
||||
"setup_devices_router_list_1": "You can usually access them from your browser via a URL, such as <code>http://192.168.0.1</code> or <code2>http://192.168.1.1</code2>. You may be asked to enter a password. You can often reset the password by pressing a button on the router itself, but be aware that you could lose your entire router configuration. If your router is to be set up through an app, install the app on your phone or PC and use it to access the router's settings.",
|
||||
"setup_devices_router_list_2_title": "In the DHCP/DNS settings, enter the addresses of your AdGuard Home server.",
|
||||
"setup_devices_router_list_2": "To find the settings, look for DNS next to a field that allows two or three sets of numbers, each divided into four groups of one to three digits.",
|
||||
"setup_devices_router_desc_2": "Some routers do not allow you to set up a custom DNS server. In this case, you can try to set up AdGuard Home as a <a>DHCP server</a>. If this does not work, please refer to your router manual for instructions on how to customize DNS servers for your specific router.",
|
||||
"setup_devices_windows_list_1": "Open <i>Control Panel</i> from the <i>Start menu</i> or <i>Windows Search</i>.",
|
||||
"setup_devices_windows_list_2": "Click <i>Network and Internet</i> → <i>Network and Sharing Center</i> → <i>Change adapter settings</i>.",
|
||||
"setup_devices_windows_list_3": "Right-click on your active connection and select <i>Properties</i>.",
|
||||
"setup_devices_windows_list_4": "In the list, select <i>Internet Protocol Version 4 (TCP/IPv4)</i> for IPv4 or <i>Internet Protocol Version 6 (TCP/IPv6)</i> for IPv6.",
|
||||
"setup_devices_windows_list_5": "Click <i>Properties</i> → <i>Use the following DNS server addresses</i>.",
|
||||
"setup_devices_windows_list_6": "Enter the addresses of your AdGuard Home server.",
|
||||
"setup_devices_dns_list_1": "For <strong>DNS-over-TLS</strong>, use <code></code>",
|
||||
"setup_devices_dns_list_2": "For <strong>DNS-over-HTTPS</strong>, use <code></code>",
|
||||
"setup_devices_dns_list_3": "For <strong>DNS-over-QUIC</strong>, use <code></code>",
|
||||
"setup_devices_dns_list_devices": "Here’s a list of software you can use.",
|
||||
"setup_devices_dns_android_list_1": "Android 9 natively supports <strong>DNS-over-TLS</strong>. To configure it, go to <i>Settings → Network & Internet → Advanced → Private DNS</i> and enter your domain name.",
|
||||
"setup_devices_dns_android_list_2": "<a>AdGuard for Android</a> supports <strong>DNS-over-HTTPS</strong>, <strong>DNS-over-TLS</strong>, and <strong>DNS-over-QUIC setup</strong>.",
|
||||
"setup_devices_dns_android_list_3": "<a>Intra</a> adds <strong>DNS-over-HTTPS</strong> support to Android.",
|
||||
"setup_devices_dns_ios_list_1": "<a>AdGuard for iOS</a> supports <strong>DNS-over-HTTPS</strong>, <strong>DNS-over-TLS</strong>, and <strong>DNS-over-QUIC</strong> setup.",
|
||||
"setup_devices_dns_ios_list_2": "<a>DNSCloak</a> supports <strong>DNS-over-HTTPS</strong>, but to configure it to use your own server, you'll need to generate a <b>DNS Stamp</b> for it.",
|
||||
"setup_devices_dns_other_list_1": "<a>AdGuard Home</a> itself can be a secure DNS client on any platform.",
|
||||
"setup_devices_dns_other_list_2": "<a>dnsproxy</a> supports all known secure DNS protocols.",
|
||||
"setup_devices_dns_other_list_3": "<a>dnscrypt-proxy</a> supports <strong>DNS-over-HTTPS</strong>.",
|
||||
"setup_devices_dns_other_list_4": "<a>Mozilla Firefox</a> supports <strong>DNS-over-HTTPS</strong>.",
|
||||
"setup_devices_dns_other_list_5": "For more implementations, see <a>DNSCrypt</a> and <b>DNS privacy</b>.",
|
||||
"setup_devices_dns_macos_desc": "On an iOS 14 or macOS Big Sur device, you can download a special mobileconfig file that adds <strong>DNS-over-HTTPS</strong> or <strong>DNS-over-TLS</strong> servers to the DNS settings.",
|
||||
"client_id_faq": "Clients can be identified by their ClientID. To learn more, visit the <a>AdGuard Home GitHub</a>",
|
||||
"device_type": "Device type",
|
||||
"setup_guide": "Setup guide",
|
||||
"setup_devices_router_title": "Router",
|
||||
"setup_devices_windows_title": "Windows",
|
||||
"setup_devices_macos_title": "macOS",
|
||||
"setup_devices_android_title": "Android",
|
||||
"setup_devices_ios_title": "iOS",
|
||||
"protection": "Protection",
|
||||
"pause_for_seconds": "| Pause for %count% second | Pause for %count% seconds",
|
||||
"pause_for_minutes": "| Pause for %count% minute | Pause for %count% minutes",
|
||||
"pause_for_hour": "Pause for %count% hour",
|
||||
"pause_until_tomorrow": "Pause until tomorrow, %time%",
|
||||
"resume_protection_timer": "Protection will resume in %time%",
|
||||
"no_stats_yet": "No stats yet — keep using AdGuard Home to view them",
|
||||
"blocked_threats_chart": "Blocked threats",
|
||||
"ads_blocked_card": "Ads blocked",
|
||||
"queries_total": "| %count% query total | %count% queries total",
|
||||
"queries": "Queries",
|
||||
"blocked_queries": "Blocked queries",
|
||||
"confirm_client_block_title": "Block client?",
|
||||
"confirm_client_block_desc": "AdGuard Home will be dropping all DNS requests from the client <strong>%ip%</strong>",
|
||||
"confirm_client_unblock_title": "Unblock client?",
|
||||
"confirm_client_unblock_desc": "DNS requests from the client <strong>%ip%</strong> will be allowed",
|
||||
"client_already_blocked": "Client <b>%ip%</b> is already blocked",
|
||||
"blocked": "Blocked",
|
||||
"block_client": "Block client",
|
||||
"unblock_client": "Unblock client",
|
||||
"client_blocked_flash": "Client blocked",
|
||||
"client_unblocked_flash": "Client unblocked",
|
||||
"dns_queries": "DNS queries",
|
||||
"dns_queries_tooltip": "The number of DNS queries processed for the last 24 hours",
|
||||
"ads_blocked": "Ads blocked",
|
||||
"ads_blocked_tooltip": "The number of DNS queries blocked by adblock filters and hosts blocklist",
|
||||
"threats_blocked": "Threats blocked",
|
||||
"threats_blocked_tooltip": "The number of DNS queries blocked by the AdGuard browsing security module",
|
||||
"adult_websites_blocked": "Adult websites blocked",
|
||||
"adult_websites_blocked_tooltip": "The number of adult websites blocked",
|
||||
"safe_search_used": "Safe search used",
|
||||
"safe_search_used_tooltip": "The number of DNS queries to search engines for which Safe Search was enforced",
|
||||
"average_time_processing": "Average processing time",
|
||||
"average_time_processing_tooltip": "Average time in milliseconds onprocessing a DNS query",
|
||||
"found_in_known_domains": "Found in the known domains",
|
||||
"name_tooltip": "Name",
|
||||
"category_tooltip": "Category",
|
||||
"source_tooltip": "Source",
|
||||
"blocked_total": "%value% blocked total",
|
||||
"copied": "Copied to clipboard",
|
||||
"copy": "Copy",
|
||||
"total": "Total",
|
||||
"setup_dns_notice_new": "In order to use <strong>DNS-over-HTTPS</strong> or <strong>DNS-over-TLS</strong>, you need to <a>configure Encryption</a> in AdGuard Home settings.",
|
||||
"setup_guide_greeting_title": "Set up AdGuard Home",
|
||||
"setup_guide_greeting_desc": "This wizard will help you configure AdGuard Home:",
|
||||
"setup_guide_greeting_list_1": "Create credentials to access the admin panel",
|
||||
"setup_guide_greeting_list_2": "Set the Web UI address for AdGuard Home",
|
||||
"setup_guide_greeting_list_3": "Set the AdGuard Home DNS server address",
|
||||
"setup_guide_greeting_list_4": "Get instructions for your individual devices",
|
||||
"setup_guide_greeting_list_5": "Get access to the AdGuard Home dashboard ",
|
||||
"setup_guide_greeting_button": "Run wizard",
|
||||
"setup_guide_auth_title": "Set up password protection",
|
||||
"setup_guide_auth_subtitle": "This is required to protect the AdGuard Home admin panel from unauthorized access",
|
||||
"setup_guide_auth_privacy": "I accept the <a>Privacy policy</a> and <b>Terms and conditions</b> of AdGuard websites",
|
||||
"password_requirements": "Password requirements",
|
||||
"password_requirements_characters": "Minimum 8 characters",
|
||||
"password_requirements_special": "English letters, numbers, and special characters",
|
||||
"password_requirements_lowercase": "At least 1 lowercase letter",
|
||||
"password_requirements_uppercase": "At least 1 uppercase letter",
|
||||
"password_requirements_match": "Passwords match",
|
||||
"setup_complete": "Setup is complete and AdGuard Home is ready to use",
|
||||
"setup_ui_title": "Set the Web UI address for AdGuard Home",
|
||||
"setup_ui_desc": "Use these addresses to access the Web user interface of AdGuard Home. If necessary, change the port",
|
||||
"setup_ui_title_banner": "Web UI settings",
|
||||
"setup_dns_title": "Set the AdGuard Home DNS server address",
|
||||
"setup_dns_title_banner": "DNS server settings",
|
||||
"network_interface": "Network interface",
|
||||
"setup_dns_desc": "Configure your router or other devices to use the following DNS server addresses:",
|
||||
"setup_dns_quote_title": "Assign a static IP address to the AdGuard Home DNS server on your local network",
|
||||
"setup_dns_quote_desc": "To function properly, the AdGuard Home DNS server requires a static IP address on your local network. Otherwise, your router may eventually assign it a different IP address and disrupt the DNS protection",
|
||||
"setup_guide_title": "Set up your devices to use AdGuard Home",
|
||||
"port_in_use": "This port is already in use",
|
||||
"forgot_password": "Forgot password?",
|
||||
"forgot_password_page_desc": "AdGuard Home stores passwords as BCrypt-encrypted hashes",
|
||||
"forgot_password_list_title": "To change your password:",
|
||||
"forgot_password_list_item_1": "Create a new password using the htpasswd tool or any online BCrypt generator.",
|
||||
"forgot_password_list_item_2": "Stop AdGuard Home.",
|
||||
"forgot_password_list_item_3": "Open AdGuardHome.yaml, find the password field, and replace it with the new value.",
|
||||
"forgot_password_list_item_4": "Start AdGuard Home.",
|
||||
"forgot_password_list_item_5": "Log in to the web interface with your new password.",
|
||||
"login": "Log in",
|
||||
"period_notify": "To change the displayed periods, edit <i>Statistics retention</i> in <a>General settings</a>",
|
||||
"password_login_error": "Your username and password do not match",
|
||||
"last_hour": "Last hour",
|
||||
"last_hours": "| Last %count% hour | Last %count% hours",
|
||||
"last_days": "| Last %count% day | Last %count% days",
|
||||
"dhcp_settings": "DHCP settings",
|
||||
"dhcp_title": "DHCP server",
|
||||
"dhcp_description": "Use AdGuard's own built-in DHCP server",
|
||||
"enabled_dhcp": "DHCP server enabled",
|
||||
"disabled_dhcp": "DHCP server disabled",
|
||||
"unavailable_dhcp": "DHCP is unavailable",
|
||||
"unavailable_dhcp_desc": "AdGuard Home cannot run a DHCP server on your OS",
|
||||
"check_dhcp_servers": "Check for DHCP servers",
|
||||
"dhcp_ipv4_settings": "DHCP IPv4 settings",
|
||||
"dhcp_ipv6_settings": "DHCP IPv6 settings",
|
||||
"dhcp_leases": "Dynamic DHCP leases",
|
||||
"dhcp_static_leases": "Static DHCP leases",
|
||||
"dhcp_leases_not_found": "No DHCP leases found",
|
||||
"dhcp_static_leases_not_found": "No DHCP static leases found",
|
||||
"dhcp_config_saved": "DHCP configuration successfully saved",
|
||||
"dhcp_not_found": "DHCP server is active",
|
||||
"dhcp_found": "DHCP server can’t be activated",
|
||||
"dhcp_warning": "If you want to enable DHCP server anyway, make sure that there is no other active DHCP server in your network, as this may break the Internet connectivity for devices on the network!",
|
||||
"dhcp_error": "Failed to check the DHCP server. Wait a bit before retrying",
|
||||
"dhcp_static_ip_error": "The DHCP server requires a static IP address. We could not confirm that this network interface has one",
|
||||
"dhcp_dynamic_ip_found": "Your system uses dynamic IP address configuration for interface <strong>%interfaceName%</strong>. In order to use DHCP server, a static IP address must be set. Your current IP address is <strong>%ipAddress%</strong>. AdGuard Home will automatically set this IP address as static if you press the \"Enable DHCP server\" button.",
|
||||
"dhcp_lease_added": "Static lease \"%key%\" successfully added",
|
||||
"dhcp_lease_deleted": "Static lease \"%key%\" successfully deleted",
|
||||
"dhcp_lease_updated": "Static lease \"%key%\" successfully updated",
|
||||
"dhcp_new_static_lease": "New static lease",
|
||||
"dhcp_edit_static_lease": "Edit static lease",
|
||||
"dhcp_add_static_lease": "Add static lease",
|
||||
"dhcp_reset_leases": "Reset all leases",
|
||||
"dhcp_reset_leases_confirm": "This will reset all leases",
|
||||
"dhcp_reset_leases_success": "DHCP leases successfully reset",
|
||||
"dhcp_reset": "This will reset the DHCP configuration. All changes will be lost",
|
||||
"dhcp_form_gateway_input": "Gateway IP",
|
||||
"dhcp_form_gateway_address": "Gateway IP address",
|
||||
"dhcp_form_subnet_input": "Subnet mask",
|
||||
"dhcp_form_range_title": "IP address range",
|
||||
"dhcp_form_range_start": "Start IP address",
|
||||
"dhcp_form_lease_title": "DHCP lease time (in seconds)",
|
||||
"dhcp_interface_select": "Select DHCP interface",
|
||||
"dhcp_hardware_address": "Hardware address",
|
||||
"dhcp_ip_addresses": "IP addresses",
|
||||
"dhcp_table_mac_address": "MAC address",
|
||||
"dhcp_table_ip_address": "IP address",
|
||||
"dhcp_table_hostname": "Hostname",
|
||||
"dhcp_mac_address_already_added": "This MAC address is already added",
|
||||
"reset_settings": "Reset settings",
|
||||
"reset_settings_confirm": "Yes, reset",
|
||||
"actions_table_header": "Actions",
|
||||
"edit_table_action": "Edit",
|
||||
"delete_table_action": "Delete",
|
||||
"delete_table_action_confirm": "Yes, delete",
|
||||
"refresh_btn": "Refresh",
|
||||
"make_static": "Make static",
|
||||
"form_enter_mac": "Enter a MAC address",
|
||||
"form_enter_ip": "Enter an IP address",
|
||||
"form_enter_hostname": "Enter a hostname",
|
||||
"delete_confirm": "Delete lease?",
|
||||
"delete_confirm_desc": "This will free <b>%ip%</b>",
|
||||
"set_static_ip_manually": "Set static IP address manually",
|
||||
"try_again": "Try again",
|
||||
"form_error_ip_already_added": "This IP address is already added",
|
||||
"form_error_mac_already_added": "This MAC address is already added",
|
||||
"form_error_hostname_format": "Use numbers, lowercase English letters, or hyphens",
|
||||
"make_static_desc": "This action converts a dynamic address to a static one",
|
||||
"show_more_count": "+ %count% more",
|
||||
"query_log": "Query log",
|
||||
"domain_or_client": "Domain or client",
|
||||
"query_log_strict_search": "Use double quotes for strict search",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"error": "Error",
|
||||
"invalid": "Invalid",
|
||||
"disallow_client_confirm_title": "Disallow this client?",
|
||||
"disallow_client_confirm_text": "AdGuard Home will be dropping all DNS queries from this client. Are you sure you want to block the client %ip%?",
|
||||
"disallow_client_confirm_allowlist_note": "This action also will exclude the rule %ip% from the list of allowed clients.",
|
||||
"type_value": "Type: %value%",
|
||||
"query_log_detail_time": "Time: <span>%value%</span>",
|
||||
"query_log_detail_date": "Date: <span>%value%</span>",
|
||||
"query_log_detail_domain": "Domain: <span>%value%</span>",
|
||||
"query_log_detail_ecs": "ECS: <span>%value%</span>",
|
||||
"query_log_detail_type": "Type: <span>%value%</span>",
|
||||
"query_log_detail_protocol": "Protocol: <span>%value%</span>",
|
||||
"query_log_detail_name": "Name: <span>%value%</span>",
|
||||
"query_log_detail_category": "Category: <span>%value%</span>",
|
||||
"query_log_detail_source": "Source: <span>%value%</span>",
|
||||
"query_log_detail_served_from_cache": "Served from cache: <span>%value%</span>",
|
||||
"query_log_detail_status": "Status: <span>%value%</span>",
|
||||
"query_log_detail_dns_server": "DNS server: <span>%value%</span>",
|
||||
"query_log_detail_elapsed": "Elapsed: <span>%value%</span>",
|
||||
"query_log_detail_response_code": "Response code: <span>%value%</span>",
|
||||
"query_log_detail_response": "Response:",
|
||||
"query_log_detail_service_name": "Service name: <span>%value%</span>",
|
||||
"query_log_detail_rules": "Rule(s):",
|
||||
"query_log_detail_original_response": "Original response:",
|
||||
"query_log_detail_address": "Address: <span>%value%</span>",
|
||||
"query_log_detail_country": "Country: <span>%value%</span>",
|
||||
"query_log_detail_network": "Network: <span>%value%</span>",
|
||||
"query_log_detail_reason": "Reason:",
|
||||
"query_log_all_reasons": "All reasons",
|
||||
"query_log_blocked_by_filter": "Blocked by filter",
|
||||
"query_log_blocked_by_parental_control": "Blocked by parental control",
|
||||
"query_log_blocked_services": "Blocked services",
|
||||
"query_log_blocked_threats": "Blocked threats",
|
||||
"query_log_custom_filtering_rules": "Custom filtering rules",
|
||||
"query_log_rewritten": "Rewritten",
|
||||
"query_log_safe_search": "Safe search",
|
||||
"query_log_all_statuses": "All statuses",
|
||||
"query_log_allowed": "Allowed",
|
||||
"query_log_blocked": "Blocked",
|
||||
"query_log_processed": "Processed",
|
||||
"status_table_header": "Status",
|
||||
"reason_table_header": "Reason",
|
||||
"query_details": "Query details",
|
||||
"client_ip": "Client IP",
|
||||
"client_location": "Client location",
|
||||
"query_log_nothing_available": "Nothing available",
|
||||
"query_log_nothing_available_rotation": "Nothing available. To collect logs, edit log rotation in <a>General settings</a>",
|
||||
"allow_service": "Allow service",
|
||||
"add_to_allowlist": "Add to allowlist",
|
||||
"notify_updated": "Updated",
|
||||
"blocked_services": "Blocked services",
|
||||
"blocked_services_desc": "Select popular websites and services that should be blocked",
|
||||
"blocked_service": "Blocked service",
|
||||
"inactivity_schedule": "Inactivity schedule",
|
||||
"inactivity_schedule_desc": "Set inactivity periods for blocking services",
|
||||
"parental_group_ai": "AI",
|
||||
"parental_group_cdn": "CDN",
|
||||
"parental_group_dating": "Dating",
|
||||
"parental_group_gambling": "Gambling, betting",
|
||||
"parental_group_gaming": "Gaming",
|
||||
"parental_group_messaging": "Messaging",
|
||||
"parental_group_privacy": "Privacy tools",
|
||||
"parental_group_shopping": "Shopping",
|
||||
"parental_group_social": "Social networks",
|
||||
"parental_group_development": "Software development",
|
||||
"parental_group_streaming": "Streaming",
|
||||
"parental_group_hosting": "Web hosting",
|
||||
"parental_group_messenger": "Messaging",
|
||||
"parental_group_social_network": "Social networks",
|
||||
"parental_group_software": "Software development",
|
||||
"search_placeholder": "Search",
|
||||
"sunday": "Sunday",
|
||||
"monday": "Monday",
|
||||
"tuesday": "Tuesday",
|
||||
"wednesday": "Wednesday",
|
||||
"thursday": "Thursday",
|
||||
"friday": "Friday",
|
||||
"saturday": "Saturday",
|
||||
"inactivity_schedule_no_schedules": "No schedule",
|
||||
"inactivity_schedule_all_day": "24h",
|
||||
"inactivity_schedule_delete_desc": "Service blocking will be active all day on <strong>%value%</strong>",
|
||||
"inactivity_schedule_add": "Add schedule",
|
||||
"inactivity_schedule_edit": "Edit schedule",
|
||||
"inactivity_schedule_delete": "Delete schedule",
|
||||
"inactivity_schedule_time_from": "Time from",
|
||||
"inactivity_schedule_time_to": "Time to",
|
||||
"inactivity_schedule_replace_desc": "This schedule will replace any existing schedules for the same day of the week. Each day can have only one inactivity period",
|
||||
"inactivity_schedule_timezone": "Timezone",
|
||||
"save_btn": "Save",
|
||||
"cancel_btn": "Cancel",
|
||||
"delete_btn": "Delete",
|
||||
"static_dhcp_leases_not_found": "Static DHCP leases not found",
|
||||
"dynamic_dhcp_leases_not_found": "Dynamic DHCP leases not found",
|
||||
"user_rules_title": "Custom filtering rules",
|
||||
"user_rules_desc": "Enter one rule per line. Use either adblock-style or hosts file syntax",
|
||||
"user_rules_placeholder": "This is a placeholder",
|
||||
"user_rules_example_block": "<code>||%domain%</code> Block access to %domain% and all its subdomains",
|
||||
"user_rules_example_unblock": "<code>@@||%domain%</code> Unblock access to %domain% and all its subdomains",
|
||||
"user_rules_example_response": "<code>127.0.0.1 %domain%</code> Respond with 127.0.0.1 for %domain% only, not for its subdomains",
|
||||
"user_rules_example_comment": "<code>! This is a comment</code> To comment, start the line with ! or #",
|
||||
"user_rules_example_comment_2": "<code># This is also a comment</code> To comment, start the line with ! or #",
|
||||
"user_rules_example_regex": "<code>/REGEX/</code> Block access to domains that match the specified regular expression",
|
||||
"user_rules_learn_more": "<a>Learn more</a> about creating your own hosts lists.",
|
||||
"user_rules_check_title": "Check domain filtering globally or for a client",
|
||||
"user_rules_check_hostname_label": "Hostname or domain name",
|
||||
"user_rules_check_hostname_placeholder": "Enter target",
|
||||
"user_rules_check_client_label": "Client identifier (name, ClientID, or IP address)",
|
||||
"user_rules_check_client_placeholder": "Enter client identifier",
|
||||
"user_rules_check_dns_record_type_label": "DNS record type",
|
||||
"user_rules_check_button": "Check",
|
||||
"user_rules_domain_blocked": "Domain is blocked",
|
||||
"user_rules_rule": "<strong>Rule:</strong> %rule%",
|
||||
"user_rules_ip": "<strong>IP addresses:</strong> %ip%",
|
||||
"user_rules_cname": "<strong>CNAME:</strong> %cname%",
|
||||
"user_rules_reason": "<strong>Reason:</strong> %reason%",
|
||||
"user_rules_status": "<strong>Status:</strong> %reason%",
|
||||
"user_rules_service": "<strong>Service:</strong> %service%",
|
||||
"user_rules_dns_record_type_placeholder": "Select DNS record type",
|
||||
"user_rules_add_to_allowlist": "Add to allowlist",
|
||||
"user_rules_domain": "<strong>Domain:</strong> %value%",
|
||||
"user_rules_redirected_to": "<strong>Redirected to:</strong> %value%",
|
||||
"user_rules_rewritten_to": "<strong>Rewritten to:</strong> %value%",
|
||||
"user_rules_source": "<strong>Source:</strong> %value%",
|
||||
"user_rules_source_blocklist": "<strong>Blocklist:</strong> %value%",
|
||||
"user_rules_source_allowlist": "<strong>Allowlist:</strong> %value%",
|
||||
"user_rules_reason_blocked_by": "Blocked by %source%",
|
||||
"user_rules_reason_filtered_by": "Filtered by %source%",
|
||||
"user_rules_reason_allowed_by": "Allowed by %source%",
|
||||
"notify_undo": "Undo",
|
||||
"user_rules_domain_is_allowed": "Domain is allowed",
|
||||
"user_rules_rule_added_to_custom_filtering_rules": "Rule added to custom filtering rules",
|
||||
"user_rules_rule_added": "User rule added: <strong>%rule%</strong>",
|
||||
"user_rules_disable_parental_control": "Disable Parental control",
|
||||
"user_rules_parental_control_disabled": "Parental control disabled",
|
||||
"user_rules_disable_browsing_security": "Disable Browsing security",
|
||||
"user_rules_browsing_security_disabled": "Browsing security disabled",
|
||||
"user_rules_disable_safe_search": "Disable Safe Search",
|
||||
"user_rules_disable_filter": "Disable filter",
|
||||
"user_rules_filter_was_disabled": "<strong>%value%</strong> was disabled",
|
||||
"user_rules_rewrite_rule_is_applied": "Rewrite rule is applied",
|
||||
"user_rules_safe_search_disabled": "Safe search disabled",
|
||||
"user_rules_domain_is_processed": "Domain is processed",
|
||||
"user_rules_no_rules_matched": "<strong>Result:</strong> No rules matched",
|
||||
"user_rules_allow_service": "Allow service",
|
||||
"user_rules_rule_removed": "Rule removed from custom filtering rules",
|
||||
"close_result": "Close result",
|
||||
"user_rules_service_allowed": "The <strong>%value%</strong> service was allowed",
|
||||
"user_rules_remove_dns_rewrite": "Remove DNS rewrite",
|
||||
"user_rules_edit_dns_rewrite": "Edit DNS rewrite",
|
||||
"user_rules_dns_rewrite_removed": "Rule removed from DNS rewrite",
|
||||
"clients_add": "Add client",
|
||||
"clients_edit": "Edit client",
|
||||
"requests_table_header": "Requests",
|
||||
"clients_remove_title": "Remove client?",
|
||||
"clients_remove_desc": "<strong>%value%</strong> will be removed from Persistent clients along with its settings",
|
||||
"clients_add_name": "Client name",
|
||||
"clients_add_default_name": "My device",
|
||||
"clients_identifiers": "Identifiers",
|
||||
"clients_identifiers_desc": "You can use the client’s IP address, CIDR, MAC address, or ClientID (for DoT, DoH, and DoQ). <a>Learn more</a>",
|
||||
"clients_add_identifier": "Add identifier",
|
||||
"clients_identifier_format_error": "Use numbers, lowercase English letters, or hyphens",
|
||||
"clients_identifier_already_used": "This identifier is already used",
|
||||
"clients_tags": "Tags",
|
||||
"clients_tags_desc": "You can add tags for this client and include them in filtering rules. <a>Learn more</a>",
|
||||
"clients_use_global_settings": "Use global settings",
|
||||
"clients_use_global_settings_desc": "Inherit protection settings from your AdGuard Home account",
|
||||
"clients_protection": "Protection",
|
||||
"clients_protection_desc": "Enable or disable protection features, logging, and statistics",
|
||||
"clients_upstreams_desc": "Upstream DNS servers, one per line",
|
||||
"clients_use_dns_cache": "Use DNS cache",
|
||||
"clients_dns_cache_size": "DNS cache size, in bytes",
|
||||
"clients_dns_cache_size_placeholder": "Use numbers",
|
||||
"remove_tag": "Remove %value%",
|
||||
"clients_logs_and_statistics": "Logs and statistics",
|
||||
"clients_dont_log": "Don't log this client",
|
||||
"clients_dont_collect_stats": "Don't collect stats for this client",
|
||||
"clients": "Clients",
|
||||
"aria_clear_input": "Clear input",
|
||||
"aria_previous_page": "Previous page",
|
||||
"aria_next_page": "Next page"
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
{
|
||||
"access_settings_title": "Configuración de acceso",
|
||||
"actions_label": "Acciones",
|
||||
"actions_table_header": "Acciones",
|
||||
"add": "Añadir",
|
||||
"add_allowlist": "Añadir lista de permitido",
|
||||
"add_blocklist": "Añadir lista de bloqueo",
|
||||
"add_persistent_client": "Añadir como cliente persistente",
|
||||
"add_to_allowlist": "Añadir a la lista de permitido",
|
||||
"ads_blocked": "Anuncios bloqueados",
|
||||
"ads_blocked_card": "Anuncios bloqueados",
|
||||
"adult_websites_blocked_tooltip": "Número de sitios web para adultos bloqueado",
|
||||
"all_lists_up_to_date_toast": "Todas las listas ya están actualizadas",
|
||||
"allowed": "Permitido",
|
||||
"allowlist_add": "Añadir lista de permitido",
|
||||
"allowlist_edit": "Editar lista de permitido",
|
||||
"apply": "Aplicar",
|
||||
"auto_clients_title": "Clientes activos",
|
||||
"autofix_warning_result": "Como resultado, todas las peticiones DNS de tu sistema serán procesadas por AdGuard Home de manera predeterminada.",
|
||||
"autofix_warning_text": "Si haces clic en \"Corregir\", AdGuard Home configurará tu sistema para utilizar el servidor DNS de AdGuard Home.",
|
||||
"average_time_processing": "Tiempo promedio de procesamiento",
|
||||
"average_upstream_response_time": "Tiempo promedio de respuesta del proveedor DNS",
|
||||
"back": "Atrás",
|
||||
"block": "Bloquear",
|
||||
"block_for_this_client_only": "Bloquear solo para este cliente",
|
||||
"blocked": "Bloqueado",
|
||||
"blocked_by_cname_or_ip": "Bloqueado por CNAME o IP",
|
||||
"blocked_service": "Servicio bloqueado",
|
||||
"blocked_services": "Servicios bloqueados",
|
||||
"blocked_threats": "Amenazas bloqueadas",
|
||||
"blocked_threats_chart": "Amenazas bloqueadas",
|
||||
"blocklist_add_manual": "Añadir manualmente",
|
||||
"blocklist_edit": "Editar lista de bloqueo",
|
||||
"blocklist_homepage": "Página de inicio",
|
||||
"blocklist_url_file_path": "URL o ruta del archivo",
|
||||
"blocklists_add": "Añadir lista de bloqueo",
|
||||
"bootstrap_dns": "Servidores DNS de arranque",
|
||||
"cache_cleared": "Caché DNS borrado correctamente",
|
||||
"cache_config_clear": "Borrar caché",
|
||||
"cache_config_optimistic": "Caché optimista",
|
||||
"cache_config_ttl_validation": "La anulación TTL mínimo de la caché debe ser menor o igual al máximo",
|
||||
"cache_enabled": "Habilitar caché",
|
||||
"cancel": "Cancelar",
|
||||
"cancel_btn": "Cancelar",
|
||||
"category_tooltip": "Categoría",
|
||||
"changes_saved_success": "Cambios guardados",
|
||||
"check_dhcp_servers": "Comprobar si hay servidores DHCP",
|
||||
"check_not_found": "No se ha encontrado en tus listas de filtros",
|
||||
"check_updates_btn": "Buscar actualizaciones",
|
||||
"clear_btn": "Borrar",
|
||||
"client_add": "Añadir cliente",
|
||||
"client_details": "Detalles del cliente",
|
||||
"client_id": "ID de cliente",
|
||||
"client_id_placeholder": "Ingresa el ID del cliente",
|
||||
"client_identifier": "Identificador",
|
||||
"client_settings": "Configuración de clientes",
|
||||
"client_table_header": "Cliente",
|
||||
"clients_add_identifier": "Añadir identificador",
|
||||
"clients_dns_cache_size": "Tamaño de la caché DNS, en bytes",
|
||||
"clients_not_found": "No se han encontrado clientes",
|
||||
"clients_protection": "Protección",
|
||||
"clients_tags": "Etiquetas",
|
||||
"clients_title": "Clientes persistentes",
|
||||
"clients_use_global_settings": "Usar configuración global",
|
||||
"close": "Cerrar",
|
||||
"confirm": "Confirmar",
|
||||
"copied": "Copiado al portapapeles",
|
||||
"copy": "Copiar",
|
||||
"custom_filter_rules": "Reglas de filtrado personalizado",
|
||||
"custom_filtering_rules": "Reglas de filtrado personalizado",
|
||||
"dark_theme": "Tema oscuro",
|
||||
"dashboard": "Panel de control",
|
||||
"delete_btn": "Eliminar",
|
||||
"delete_table_action": "Eliminar",
|
||||
"dhcp_add_static_lease": "Añadir asignación estática",
|
||||
"dhcp_config_saved": "Configuración DHCP guardado correctamente",
|
||||
"dhcp_edit_static_lease": "Editar asignación estática",
|
||||
"dhcp_form_gateway_input": "IP de puerta de enlace",
|
||||
"dhcp_form_lease_title": "Tiempo de asignación DHCP (en segundos)",
|
||||
"dhcp_form_subnet_input": "Máscara de subred",
|
||||
"dhcp_hardware_address": "Dirección MAC",
|
||||
"dhcp_interface_select": "Seleccionar interfaz DHCP",
|
||||
"dhcp_ip_addresses": "Direcciones IP",
|
||||
"dhcp_leases_not_found": "No se han encontrado asignaciones DHCP",
|
||||
"dhcp_new_static_lease": "Nueva asignación estática",
|
||||
"dhcp_reset_leases": "Restablecer todas las asignaciones",
|
||||
"dhcp_reset_leases_success": "Asignaciones DHCP restablecidas correctamente",
|
||||
"dhcp_settings": "Configuración DHCP",
|
||||
"dhcp_static_leases_not_found": "No se han encontrado asignaciones DHCP estáticas",
|
||||
"dhcp_table_hostname": "Nombre del host",
|
||||
"dhcp_table_ip_address": "Dirección IP",
|
||||
"dhcp_warning": "Si de todos modos deseas habilitar el servidor DHCP, asegúrate de que no hay otro servidor DHCP activo en tu red. ¡De lo contrario, puedes dejar sin conexión a Internet a los dispositivos conectados!",
|
||||
"disable": "Deshabilitar",
|
||||
"disable_notify_until_tomorrow": "Deshabilitar protección hasta mañana",
|
||||
"disable_protection_btn": "Deshabilitar protección",
|
||||
"disabled_dhcp": "Servidor DHCP deshabilitado",
|
||||
"disabled_protection": "Protección deshabilitada",
|
||||
"disallow_this_client": "No permitir a este cliente",
|
||||
"dns_cache_config": "Configuración de la caché DNS",
|
||||
"dns_config": "Configuración del servidor DNS",
|
||||
"dns_over_https": "DNS mediante HTTPS",
|
||||
"dns_over_quic": "DNS-over-QUIC",
|
||||
"dns_over_tls": "DNS mediante TLS",
|
||||
"dns_privacy": "DNS cifrado",
|
||||
"dns_query": "Consultas DNS",
|
||||
"dns_rewrites": "Reescrituras DNS",
|
||||
"dns_settings": "Configuración DNS",
|
||||
"dns_test_ok_toast": "Los servidores DNS especificados funcionan correctamente",
|
||||
"domain": "Dominio",
|
||||
"domain_or_client": "Dominio o cliente",
|
||||
"down": "Abajo",
|
||||
"download_mobileconfig": "Descargar archivo de configuración",
|
||||
"edit_table_action": "Editar",
|
||||
"enable": "Habilitar",
|
||||
"enabled_dhcp": "Servidor DHCP habilitado",
|
||||
"enabled_protection": "Protección habilitada",
|
||||
"enabled_table_header": "Habilitado",
|
||||
"encryption_certificate_path": "Ruta de acceso al certificado",
|
||||
"encryption_certificates": "Certificados",
|
||||
"encryption_certificates_input": "Copia/pega aquí tu certificado codificado PEM.",
|
||||
"encryption_certificates_source_content": "Pegar el contenido del certificado",
|
||||
"encryption_certificates_source_path": "Establecer una ruta para el archivo de certificado",
|
||||
"encryption_chain_invalid": "La cadena de certificado no es válida",
|
||||
"encryption_chain_valid": "La cadena de certificado es válida",
|
||||
"encryption_config_saved": "Configuración de cifrado guardado",
|
||||
"encryption_doq": "Puerto DNS mediante QUIC",
|
||||
"encryption_dot": "Puerto DNS mediante TLS",
|
||||
"encryption_https": "Puerto HTTPS",
|
||||
"encryption_key": "Clave privada",
|
||||
"encryption_key_input": "Copia/pega aquí tu clave privada codificada PEM para tu certificado.",
|
||||
"encryption_key_source_content": "Pegar el contenido de la clave privada",
|
||||
"encryption_plain_dns": "DNS simple",
|
||||
"encryption_plain_dns_error": "Para deshabilitar el DNS simple, habilita al menos un protocolo DNS cifrado",
|
||||
"encryption_private_key_path": "Ruta de acceso a la clave privada",
|
||||
"encryption_server": "Nombre del servidor",
|
||||
"encryption_server_enter": "Ingresa el nombre del dominio",
|
||||
"encryption_title": "Cifrado",
|
||||
"enter_cache_size": "Ingresa el tamaño de la caché (bytes)",
|
||||
"enter_ip_address_placeholder": "Ingrese la dirección IP",
|
||||
"error": "Hubo un error",
|
||||
"fallback_dns": "Servidores DNS alternativos",
|
||||
"filter_category_general": "General",
|
||||
"filter_category_general_desc": "Listas que bloquean rastreadores y anuncios en la mayoría de los dispositivos",
|
||||
"filter_category_other": "Otros",
|
||||
"filter_category_other_desc": "Otras listas de bloqueo",
|
||||
"filter_category_regional": "Regional",
|
||||
"filter_category_regional_desc": "Listas que se centran en anuncios regionales y servidores de rastreo",
|
||||
"filter_category_security": "Seguridad",
|
||||
"filter_category_security_desc": "Listas diseñadas específicamente para bloquear dominios de malware, phishing y estafa",
|
||||
"filter_removed_successfully": "La lista ha sido eliminada correctamente",
|
||||
"filters": "Suscripciones a filtros",
|
||||
"forgot_password": "¿Olvidaste tu contraseña?",
|
||||
"form_enter_blocked_response_ttl": "Ingresa el TTL de respuesta bloqueada (en segundos)",
|
||||
"form_error_answer_format": "Formato de respuesta no válido",
|
||||
"form_error_client_id_format": "El ID de cliente debe contener solo números, letras minúsculas y guiones",
|
||||
"form_error_domain_format": "Formato de dominio no válido",
|
||||
"form_error_equal": "No debe ser igual",
|
||||
"form_error_gateway_ip": "La asignación no puede tener la dirección IP de la puerta de enlace",
|
||||
"form_error_ip4_format": "Dirección IPv4 no válida",
|
||||
"form_error_ip6_format": "Dirección IPv6 no válida",
|
||||
"form_error_ip_format": "Dirección IP no válida",
|
||||
"form_error_mac_format": "Dirección MAC no válida",
|
||||
"form_error_password": "La contraseña no coincide",
|
||||
"form_error_port": "Ingresa un número de puerto válido",
|
||||
"form_error_port_range": "Ingresa el número del puerto en el rango de 80 a 65535",
|
||||
"form_error_port_unsafe": "Puerto inseguro",
|
||||
"form_error_required": "Por favor, completa este campo",
|
||||
"form_error_server_name": "Nombre de servidor no válido",
|
||||
"form_error_url_or_path_format": "URL o ruta absoluta no válida para la lista",
|
||||
"friday": "Viernes",
|
||||
"gateway_or_subnet_invalid": "Máscara de subred no válida",
|
||||
"general_settings": "Configuración general",
|
||||
"general_statistics": "Estadísticas generales",
|
||||
"greater_range_start_error": "Debe ser mayor que el inicio de rango",
|
||||
"ignore_domains_desc_query": "Las consultas que coinciden con estas reglas no aparecen en el registro de consultas",
|
||||
"ignore_domains_title": "Dominios ignorados",
|
||||
"inactivity_schedule_add": "Añadir horario",
|
||||
"inactivity_schedule_edit": "Editar horario",
|
||||
"install_auth_confirm": "Confirmar contraseña",
|
||||
"install_auth_password": "Contraseña",
|
||||
"install_auth_password_enter": "Ingresa tu contraseña",
|
||||
"install_auth_username": "Nombre de usuario",
|
||||
"install_auth_username_enter": "Ingresa tu nombre de usuario",
|
||||
"install_saved": "Guardado correctamente",
|
||||
"install_settings_all_interfaces": "Todas las interfaces",
|
||||
"install_settings_port": "Puerto",
|
||||
"install_static_error": "AdGuard Home no puede configurarlo automáticamente para esta interfaz de red. Busca instrucciones sobre cómo hacer esto manualmente.",
|
||||
"install_static_ok": "¡Buenas noticias! La dirección IP estática ya está configurada",
|
||||
"install_step": "Paso",
|
||||
"install_submit_title": "¡Felicitaciones!",
|
||||
"ip_address": "Dirección IP",
|
||||
"known_tracker": "Rastreador conocido",
|
||||
"light_theme": "Tema claro",
|
||||
"login": "Iniciar sesión",
|
||||
"logout": "Cerrar sesión",
|
||||
"make_static": "Hacer estático",
|
||||
"milliseconds_abbreviation": "ms",
|
||||
"monday": "Lunes",
|
||||
"name": "Nombre",
|
||||
"name_label": "Nombre",
|
||||
"name_table_header": "Nombre",
|
||||
"name_tooltip": "Nombre",
|
||||
"next": "Siguiente",
|
||||
"no": "No",
|
||||
"nothing_found": "No se ha encontrado nada",
|
||||
"notify_undo": "Deshacer",
|
||||
"notify_updated": "Actualizado",
|
||||
"parental_control": "Control parental",
|
||||
"parental_group_cdn": "CDN",
|
||||
"parental_group_development": "Desarrollo de software",
|
||||
"parental_group_gaming": "Juegos",
|
||||
"parental_group_hosting": "Web hosting",
|
||||
"parental_group_privacy": "Herramientas de privacidad",
|
||||
"parental_group_shopping": "Compras",
|
||||
"parental_group_social": "Redes sociales",
|
||||
"parental_group_social_network": "Redes sociales",
|
||||
"parental_group_software": "Desarrollo de software",
|
||||
"parental_group_streaming": "Streaming",
|
||||
"password_label": "Contraseña",
|
||||
"password_placeholder": "Ingresa tu contraseña",
|
||||
"plain_dns": "DNS simple",
|
||||
"privacy_policy": "Política de privacidad",
|
||||
"protection": "Protección",
|
||||
"protocol": "Protocolo",
|
||||
"query_log_allowed": "Permitido",
|
||||
"query_log_blocked": "Bloqueado",
|
||||
"query_log_blocked_services": "Servicios bloqueados",
|
||||
"query_log_blocked_threats": "Amenazas bloqueadas",
|
||||
"query_log_custom_filtering_rules": "Reglas de filtrado personalizado",
|
||||
"query_log_processed": "Procesado",
|
||||
"query_log_retention": "Rotanción de registros de consultas",
|
||||
"query_log_rewritten": "Reescrito",
|
||||
"query_log_safe_search": "Búsqueda segura",
|
||||
"query_log_strict_search": "Usar comillas dobles para una búsqueda estricta",
|
||||
"rate_limit_subnet_len_ipv4_error": "La longitud del prefijo de subred IPv4 debe estar entre 0 y 32",
|
||||
"rate_limit_subnet_len_ipv6_error": "La longitud del prefijo de subred IPv6 debe estar entre 0 y 128",
|
||||
"refresh_btn": "Actualizar",
|
||||
"refresh_statics": "Actualizar estadísticas",
|
||||
"release_notes": "Notas de lanzamiento",
|
||||
"remove": "Eliminar",
|
||||
"report_an_issue": "Reportar un error",
|
||||
"request_table_header": "Petición",
|
||||
"requests_table_header": "Peticiones",
|
||||
"reset": "Restablecer",
|
||||
"reset_settings": "Restablecer configuración",
|
||||
"reset_settings_confirm": "Sí, reiniciar",
|
||||
"response_details": "Detalles de la respuesta",
|
||||
"response_time": "Tiempo de respuesta",
|
||||
"result": "Resultado",
|
||||
"rewrite_add": "Añadir reescritura DNS",
|
||||
"rewrite_edit": "Editar reescritura DNS",
|
||||
"rewrite_hosts_applied": "Reescrito por la regla del archivo hosts",
|
||||
"rewrite_settings_updated": "La configuración de reescritura de DNS se actualizó correctamente",
|
||||
"rewrite_updated": "Reescritura DNS actualizada correctamente",
|
||||
"rewritten": "Reescrito",
|
||||
"rules_label": "Reglas",
|
||||
"safe_browsing": "Navegación segura",
|
||||
"safe_search": "Búsqueda segura",
|
||||
"saturday": "Sábado",
|
||||
"save": "Guardar",
|
||||
"save_btn": "Guardar",
|
||||
"search_placeholder": "Buscar",
|
||||
"server_config_blocking_mode": "Modo de bloqueo",
|
||||
"server_config_blocking_mode_ipv4": "Bloqueo de IPv4",
|
||||
"server_config_blocking_mode_ipv6": "Bloqueo de IPv6",
|
||||
"server_config_custom_ip": "Dirección IP personalizada",
|
||||
"server_config_default": "Predeterminado",
|
||||
"server_config_disable_ipv6": "Deshabilitar resolución de direcciones IPv6",
|
||||
"server_config_dnssec_enable": "Habilitar DNSSEC",
|
||||
"server_config_edns_enable": "Habilitar subred de cliente EDNS",
|
||||
"server_config_nxdomain": "NXDOMAIN",
|
||||
"server_config_rate_limit_placeholder": "Ingresa el límite de cantidad",
|
||||
"server_config_rate_limit_whitelist_faq": "Direcciones IP excluidas del límite de cantidad",
|
||||
"server_config_refused": "REFUSED",
|
||||
"server_config_subnet_len_ipv4": "Longitud del prefijo de subred para direcciones IPv4",
|
||||
"server_config_subnet_len_ipv4_faq": "Longitud del prefijo de subred para direcciones IPv4 utilizadas para limitar la cantidad. El valor predeterminado es 24",
|
||||
"server_config_subnet_len_ipv6": "Longitud del prefijo de subred para direcciones IPv6",
|
||||
"server_config_subnet_len_ipv6_faq": "Longitud del prefijo de subred para direcciones IPv6 utilizadas para limitar la cantidad. El valor predeterminado es 56",
|
||||
"set_static_ip": "Establecer una dirección IP estática",
|
||||
"settings": "Configuración",
|
||||
"settings_browsing_security": "Seguridad de navegación",
|
||||
"settings_confirm_clear_statistics": "¿Borrar estadísticas?",
|
||||
"settings_custom": "Personalizado",
|
||||
"settings_general_short": "General",
|
||||
"settings_global": "Global",
|
||||
"settings_notify_changes_saved": "Cambios guardados",
|
||||
"settings_notify_statistics_cleared": "Estadísticas borradas",
|
||||
"settings_parental_control": "Control parental",
|
||||
"settings_rule_syntax": "Sintaxis de regla",
|
||||
"settings_safe_search": "Búsqueda segura",
|
||||
"settings_statistics": "Estadísticas",
|
||||
"settings_statistics_clear": "Borrar estadísticas",
|
||||
"settings_statistics_retention": "Retención de estadísticas",
|
||||
"settings_yes_clear": "Sí, borrar",
|
||||
"setup_devices_router_title": "Router",
|
||||
"setup_dns_privacy_ioc_mac": "Configuración de iOS y macOS",
|
||||
"setup_dns_privacy_other_title": "Otras implementaciones",
|
||||
"show_blocked_responses": "Bloqueado",
|
||||
"show_processed_responses": "Procesado",
|
||||
"source_label": "Fuente",
|
||||
"source_tooltip": "Fuente",
|
||||
"stats_adult": "Sitios web para adultos bloqueado",
|
||||
"stats_query_domain": "Dominios más consultados",
|
||||
"status_table_header": "Estado",
|
||||
"subnet_error": "Las direcciones deben estar en una subred",
|
||||
"sunday": "Domingo",
|
||||
"system_host_files": "Archivos hosts del sistema",
|
||||
"table_client": "Cliente",
|
||||
"tags_title": "Etiquetas",
|
||||
"test_upstreams": "Probar proveedores DNS",
|
||||
"threats_blocked": "Amenazas bloqueadas",
|
||||
"thursday": "Jueves",
|
||||
"time_table_header": "Hora",
|
||||
"top_blocked_domains": "Dominios más bloqueados",
|
||||
"top_clients": "Clientes más frecuentes",
|
||||
"top_upstreams": "Proveedores DNS más frecuentes",
|
||||
"total": "Total",
|
||||
"try_again": "Volver a intentar",
|
||||
"tuesday": "Martes",
|
||||
"unavailable_dhcp": "DHCP no disponible",
|
||||
"unavailable_dhcp_desc": "AdGuard Home no puede ejecutar un servidor DHCP en tu SO",
|
||||
"unblock": "Desbloquear",
|
||||
"update_filters_disable": "Deshabilitar",
|
||||
"update_filters_interval_custom": "Personalizado",
|
||||
"update_filters_interval_daily": "Todos los días",
|
||||
"update_filters_interval_hourly": "Cada hora",
|
||||
"update_filters_title": "Actualizar filtros automáticamente",
|
||||
"updated_custom_filtering_toast": "Reglas personalizadas guardadas correctamente",
|
||||
"updated_upstream_dns_toast": "Proveedores DNS guardados correctamente",
|
||||
"updates_checked": "La nueva versión de AdGuard Home está disponible",
|
||||
"updates_version_equal": "AdGuard Home está actualizado",
|
||||
"upstream": "Proveedor DNS",
|
||||
"upstream_dns": "Proveedores DNS",
|
||||
"upstream_dns_addresses": "Direcciones de servidor, una por línea",
|
||||
"upstream_dns_fastest_addr": "Dirección IP más rápida",
|
||||
"upstream_dns_load_balancing": "Balanceo de carga",
|
||||
"upstream_dns_parallel_requests": "Consultas paralelas",
|
||||
"upstream_dns_servers_title": "Proveedores DNS",
|
||||
"upstream_examples_title": "Ejemplos",
|
||||
"upstream_private_ptr_resolvers_title": "Usar resolutores DNS inversos y privados",
|
||||
"upstreams": "Proveedores DNS",
|
||||
"url_label": "URL",
|
||||
"use_saved_key": "Usar la clave guardada previamente",
|
||||
"user_rules": "Reglas del usuario",
|
||||
"user_rules_add_to_allowlist": "Añadir a la lista de permitido",
|
||||
"user_rules_check_button": "Comprobar",
|
||||
"user_rules_check_client_placeholder": "Ingresa el identificador del cliente",
|
||||
"user_rules_check_hostname_label": "Nombre de host o nombre de dominio",
|
||||
"user_rules_disable_filter": "Deshabilitar filtro",
|
||||
"user_rules_disable_parental_control": "Deshabilitar control parental",
|
||||
"user_rules_dns_record_type_placeholder": "Selecciona el tipo de registro DNS",
|
||||
"user_rules_domain_blocked": "Dominio bloqueado",
|
||||
"user_rules_edit_dns_rewrite": "Editar reescritura DNS",
|
||||
"user_rules_rewrite_rule_is_applied": "Regla de reescritura aplicada",
|
||||
"user_rules_title": "Reglas de filtrado personalizado",
|
||||
"username_label": "Nombre de usuario",
|
||||
"username_placeholder": "Ingresa tu nombre de usuario",
|
||||
"validated_with_dnssec": "Validado con DNSSEC",
|
||||
"wednesday": "Miércoles",
|
||||
"whois": "WHOIS",
|
||||
"yes": "Sí"
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
{
|
||||
"access_settings_title": "Paramètres d'accès",
|
||||
"actions_label": "Actions",
|
||||
"actions_table_header": "Actions",
|
||||
"add": "Ajouter",
|
||||
"add_allowlist": "Ajouter liste d’autorisation",
|
||||
"add_blocklist": "Ajouter liste de blocage",
|
||||
"add_persistent_client": "Ajouter comme client persistant",
|
||||
"add_to_allowlist": "Ajouter à la liste blanche",
|
||||
"ads_blocked": "Annonces bloquées",
|
||||
"ads_blocked_card": "Annonces bloquées",
|
||||
"adult_websites_blocked_tooltip": "Le nombre de sites à contenu adulte bloqués",
|
||||
"all_lists_up_to_date_toast": "Toutes les listes sont déjà à jour",
|
||||
"allowed": "En Liste blanche",
|
||||
"allowlist_add": "Ajouter liste d’autorisation",
|
||||
"allowlist_edit": "Modifier la liste d’autorisation",
|
||||
"apply": "Appliquer",
|
||||
"auto_clients_title": "Clients d'exécution",
|
||||
"autofix_warning_result": "Par conséquent, toutes les demandes DNS de votre système seront traitées par AdGuardHome par défaut.",
|
||||
"autofix_warning_text": "Si vous cliquez sur « Réparer », AdGuard Home configurera votre système pour utiliser le serveur DNS AdGuard Home.",
|
||||
"average_time_processing": "Temps moyen de traitement",
|
||||
"average_upstream_response_time": "Temps de réponse moyen en amont",
|
||||
"back": "Retour",
|
||||
"block": "Bloquer",
|
||||
"block_for_this_client_only": "Bloquer uniquement pour ce client",
|
||||
"blocked": "Bloqué",
|
||||
"blocked_by_cname_or_ip": "Bloqué par CNAME ou adresse IP",
|
||||
"blocked_service": "Service bloqué",
|
||||
"blocked_services": "Services bloqués",
|
||||
"blocked_threats": "Menaces bloquées",
|
||||
"blocked_threats_chart": "Menaces bloquées",
|
||||
"blocklist_add_manual": "Ajouter manuellement",
|
||||
"blocklist_edit": "Modifier la liste de blocage",
|
||||
"blocklist_homepage": "Page d'accueil",
|
||||
"blocklist_url_file_path": "URL ou chemin du fichier ",
|
||||
"blocklists_add": "Ajouter liste de blocage",
|
||||
"bootstrap_dns": "Serveurs DNS d'amorçage",
|
||||
"cache_cleared": "Le cache DNS a été vidé",
|
||||
"cache_config_clear": "Vider le cache",
|
||||
"cache_config_optimistic": "Caching optimiste",
|
||||
"cache_config_ttl_validation": "La valeur TTL minimale du cache doit être inférieure ou égale à la valeur maximale",
|
||||
"cache_enabled": "Activer le cache",
|
||||
"cancel": "Annuler",
|
||||
"cancel_btn": "Annuler",
|
||||
"category_tooltip": "Catégorie",
|
||||
"changes_saved_success": "Modifications enregistrées",
|
||||
"check_dhcp_servers": "Rechercher les serveurs DHCP",
|
||||
"check_not_found": "Introuvable dans vos listes de filtres",
|
||||
"check_updates_btn": "Vérifier les mises à jour",
|
||||
"clear_btn": "Effacer",
|
||||
"client_add": "Ajouter un client",
|
||||
"client_details": "Détails du client",
|
||||
"client_id": "ClientID",
|
||||
"client_id_placeholder": "Saisissez le ClientID",
|
||||
"client_identifier": "Identifiant",
|
||||
"client_settings": "Paramètres du client",
|
||||
"client_table_header": "Client",
|
||||
"clients_add_identifier": "Ajouter identifiant",
|
||||
"clients_dns_cache_size": "Taille du cache DNS, en bytes",
|
||||
"clients_not_found": "Pas de clients trouvés",
|
||||
"clients_protection": "Protection",
|
||||
"clients_tags": "Mots clés",
|
||||
"clients_title": "Clients persistants",
|
||||
"clients_use_global_settings": "Utiliser les paramètres généraux",
|
||||
"close": "Fermer",
|
||||
"confirm": "Confirmer",
|
||||
"copied": "Copié dans le Presse-papiers",
|
||||
"copy": "Copier",
|
||||
"custom_filter_rules": "Règles de filtrage d'utilisateur",
|
||||
"custom_filtering_rules": "Règles de filtrage d'utilisateur",
|
||||
"dark_theme": "Thème sombre",
|
||||
"dashboard": "Tableau de bord",
|
||||
"delete_btn": "Supprimer",
|
||||
"delete_table_action": "Supprimer",
|
||||
"dhcp_add_static_lease": "Ajoutez un bail statique",
|
||||
"dhcp_config_saved": "Configuration du serveur DHCP sauvegardée",
|
||||
"dhcp_edit_static_lease": "Modifier le bail statique",
|
||||
"dhcp_form_gateway_input": "IP de la passerelle",
|
||||
"dhcp_form_lease_title": "Période de location du serveur DHCP (secondes)",
|
||||
"dhcp_form_subnet_input": "Masque de sous-réseau",
|
||||
"dhcp_hardware_address": "Adresse de la machine",
|
||||
"dhcp_interface_select": "Sélectionner l'interface du serveur DHCP",
|
||||
"dhcp_ip_addresses": "Adresses IP",
|
||||
"dhcp_leases_not_found": "Aucun bail DHCP trouvé",
|
||||
"dhcp_new_static_lease": "Nouveau bail statique",
|
||||
"dhcp_reset_leases": "Réinitialiser tous les baux",
|
||||
"dhcp_reset_leases_success": "Les baux DHCP ont été réinitialisés avec succès",
|
||||
"dhcp_settings": "Paramètres DHCP",
|
||||
"dhcp_static_leases_not_found": "Aucun bail statique DHCP trouvé",
|
||||
"dhcp_table_hostname": "Nom d'hôte",
|
||||
"dhcp_table_ip_address": "Adresse IP",
|
||||
"dhcp_warning": "Si vous souhaitez tout de même activer le serveur DHCP, assurez-vous qu'il n'y a pas d'autre serveur DHCP actif sur votre réseau. Sinon, cela peut perturber la connexion Internet sur tous les appareils connectés au réseau !",
|
||||
"disable": "Désactiver",
|
||||
"disable_notify_until_tomorrow": "Désactiver la protection jusqu'à demain",
|
||||
"disable_protection_btn": "Désactiver la protection",
|
||||
"disabled_dhcp": "Serveur DHCP désactivé",
|
||||
"disabled_protection": "Protection désactivée",
|
||||
"disallow_this_client": "Interdire ce client",
|
||||
"dns_cache_config": "Configuration du cache DNS",
|
||||
"dns_config": "Configuration du serveur DNS",
|
||||
"dns_over_https": "DNS-over-HTTPS",
|
||||
"dns_over_quic": "DNS-over-QUIC",
|
||||
"dns_over_tls": "DNS-over-TLS",
|
||||
"dns_privacy": "Confidentialité DNS",
|
||||
"dns_query": "Requêtes DNS",
|
||||
"dns_rewrites": "Réécritures DNS",
|
||||
"dns_settings": "Paramètres DNS",
|
||||
"dns_test_ok_toast": "Les serveurs DNS spécifiés fonctionnent correctement",
|
||||
"domain": "Domaine",
|
||||
"domain_or_client": "Domaine ou client",
|
||||
"down": "Descendant",
|
||||
"download_mobileconfig": "Télécharger le fichier de configuration",
|
||||
"edit_table_action": "Éditer",
|
||||
"enable": "Activer",
|
||||
"enabled_dhcp": "Serveur DHCP activé",
|
||||
"enabled_protection": "Protection activée",
|
||||
"enabled_table_header": "Activé",
|
||||
"encryption_certificate_path": "Emplacement du certificat",
|
||||
"encryption_certificates": "Certificats",
|
||||
"encryption_certificates_input": "Copiez/coller vos certificats encodés PEM ici.",
|
||||
"encryption_certificates_source_content": "Coller les contenus des certificats",
|
||||
"encryption_certificates_source_path": "Définir un emplacement du fichier des certificats",
|
||||
"encryption_chain_invalid": "Chaîne de certificat invalide",
|
||||
"encryption_chain_valid": "Chaîne de certificat valide.",
|
||||
"encryption_config_saved": "Configuration de chiffrement enregistrée",
|
||||
"encryption_doq": "Port DNS sur QUIC",
|
||||
"encryption_dot": "Port DNS-over-TLS",
|
||||
"encryption_https": "Port HTTPS",
|
||||
"encryption_key": "Clé privée",
|
||||
"encryption_key_input": "Copiez/coller votre clé privée PEM encodée pour votre certificat ici.",
|
||||
"encryption_key_source_content": "Coller les contenus de la clef privée",
|
||||
"encryption_plain_dns": "DNS brut",
|
||||
"encryption_plain_dns_error": "Pour désactiver le DNS simple, activez au moins un protocole DNS crypté",
|
||||
"encryption_private_key_path": "Emplacement de la clef privée",
|
||||
"encryption_server": "Nom du serveur",
|
||||
"encryption_server_enter": "Entrez votre nom de domaine",
|
||||
"encryption_title": "Chiffrement",
|
||||
"enter_cache_size": "Entrer la taille du cache (octets)",
|
||||
"enter_ip_address_placeholder": "Saisissez l'adresse IP",
|
||||
"error": "Erreur",
|
||||
"fallback_dns": "Serveurs DNS de repli",
|
||||
"filter_category_general": "General",
|
||||
"filter_category_general_desc": "Listes qui bloquent le suivi et la publicité sur la plupart des appareils",
|
||||
"filter_category_other": "Autres",
|
||||
"filter_category_other_desc": "Autres listes noires",
|
||||
"filter_category_regional": "Régional",
|
||||
"filter_category_regional_desc": "Listes axées sur les annonces régionales et les serveurs de suivi",
|
||||
"filter_category_security": "Sécurité",
|
||||
"filter_category_security_desc": "Listes créées exprès pour bloquer les logiciels malveillants, des domaines hameçonneurs ou frauduleux",
|
||||
"filter_removed_successfully": "La liste a été supprimée avec succès",
|
||||
"filters": "Filtres",
|
||||
"forgot_password": "Mot de passe oublié ?",
|
||||
"form_enter_blocked_response_ttl": "Saisir le TTL de la réponse bloquée (secondes)",
|
||||
"form_error_answer_format": "Format de réponse invalide",
|
||||
"form_error_client_id_format": "L'ID du client ne doit contenir que des chiffres, des lettres minuscules et des traits d'union",
|
||||
"form_error_domain_format": "Format de domaine invalide",
|
||||
"form_error_equal": "Ne doit pas être égal",
|
||||
"form_error_gateway_ip": "Le bail ne peut pas avoir d'adresse IP de la passerelle",
|
||||
"form_error_ip4_format": "Adresse IPv4 invalide",
|
||||
"form_error_ip6_format": "Adresse IPv6 invalide",
|
||||
"form_error_ip_format": "Adresse e-mail non valide",
|
||||
"form_error_mac_format": "Adresse MAC invalide",
|
||||
"form_error_password": "Mots de passe différents",
|
||||
"form_error_port": "Saisissez un numéro de port valide",
|
||||
"form_error_port_range": "Saisissez une valeur de port entre 80 et 65535",
|
||||
"form_error_port_unsafe": "Port non fiable",
|
||||
"form_error_required": "Veuillez remplir ce champ",
|
||||
"form_error_server_name": "Nom de serveur invalide",
|
||||
"form_error_url_or_path_format": "Lien URL soit chemin absolu de la liste invalide",
|
||||
"friday": "Vendredi",
|
||||
"gateway_or_subnet_invalid": "Masque de sous-réseau invalide.",
|
||||
"general_settings": "Paramètres généraux",
|
||||
"general_statistics": "Statistiques générales",
|
||||
"greater_range_start_error": "Doit être supérieur au début de plage",
|
||||
"ignore_domains_desc_query": "Les requêtes correspondantes à ces règles ne sont pas écrites dans le journal des requêtes",
|
||||
"ignore_domains_title": "Domaines ignorés",
|
||||
"inactivity_schedule_add": "Ajouter un horaire",
|
||||
"inactivity_schedule_edit": "Modifier l'horaire",
|
||||
"install_auth_confirm": "Confirmer le mot de passe",
|
||||
"install_auth_password": "Mot de passe",
|
||||
"install_auth_password_enter": "Entrer le mot de passe",
|
||||
"install_auth_username": "Nom d'utilisateur",
|
||||
"install_auth_username_enter": "Saisir un nom d'utilisateur",
|
||||
"install_saved": "Enregistré avec succès",
|
||||
"install_settings_all_interfaces": "Toutes les interfaces",
|
||||
"install_settings_port": "Port",
|
||||
"install_static_error": "AdGuard Home ne peut pas le configurer automatiquement pour cette interface réseau. Veuillez rechercher une instruction sur la façon de procéder manuellement.",
|
||||
"install_static_ok": "Bonne nouvelle ! L’adresse IP statique est déjà configurée",
|
||||
"install_step": "Étape",
|
||||
"install_submit_title": "Félicitations !",
|
||||
"ip_address": "Adresse IP",
|
||||
"known_tracker": "Traqueur connu",
|
||||
"light_theme": "Thème clair",
|
||||
"login": "Connexion",
|
||||
"logout": "Déconnexion",
|
||||
"make_static": "Rendre statique",
|
||||
"milliseconds_abbreviation": "ms",
|
||||
"monday": "Lundi",
|
||||
"name": "Nom",
|
||||
"name_label": "Nom",
|
||||
"name_table_header": "Nom",
|
||||
"name_tooltip": "Nom",
|
||||
"next": "Suivant",
|
||||
"no": "Non",
|
||||
"nothing_found": "Rien n'a été trouvé",
|
||||
"notify_undo": "Annuler",
|
||||
"notify_updated": "Mis à jour",
|
||||
"parental_control": "Contrôle Parental",
|
||||
"parental_group_cdn": "CDN",
|
||||
"parental_group_development": "Développement de logiciels",
|
||||
"parental_group_gaming": "Jeux vidéo",
|
||||
"parental_group_hosting": "Hébergement Web",
|
||||
"parental_group_privacy": "Outils de confidentialité",
|
||||
"parental_group_shopping": "Shopping",
|
||||
"parental_group_social": "Réseaux sociaux",
|
||||
"parental_group_social_network": "Réseaux sociaux",
|
||||
"parental_group_software": "Développement de logiciels",
|
||||
"parental_group_streaming": "Streaming",
|
||||
"password_label": "Mot de passe",
|
||||
"password_placeholder": "Entrer le mot de passe",
|
||||
"plain_dns": "DNS brut",
|
||||
"privacy_policy": "Politique de confidentialité",
|
||||
"protection": "Protection",
|
||||
"protocol": "Protocole",
|
||||
"query_log_allowed": "En Liste blanche",
|
||||
"query_log_blocked": "Bloqué",
|
||||
"query_log_blocked_services": "Services bloqués",
|
||||
"query_log_blocked_threats": "Menaces bloquées",
|
||||
"query_log_custom_filtering_rules": "Règles de filtrage d'utilisateur",
|
||||
"query_log_processed": "Traités",
|
||||
"query_log_retention": "Rotation des journaux de requêtes",
|
||||
"query_log_rewritten": "Réécrit",
|
||||
"query_log_safe_search": "Recherche sécurisée",
|
||||
"query_log_strict_search": "Utilisez les doubles guillemets pour une recherche stricte",
|
||||
"rate_limit_subnet_len_ipv4_error": "La longueur du préfixe du sous-réseau IPv4 doit être entre 0 et 32",
|
||||
"rate_limit_subnet_len_ipv6_error": "La longueur du préfixe du sous-réseau IPv6 doit être entre 0 et 128",
|
||||
"refresh_btn": "Actualiser",
|
||||
"refresh_statics": "Actualiser les statistiques",
|
||||
"release_notes": "Notes de mise à jour",
|
||||
"remove": "Supprimer",
|
||||
"report_an_issue": "Signaler un problème",
|
||||
"request_table_header": "Requête",
|
||||
"requests_table_header": "Requêtes",
|
||||
"reset": "Réinitialiser",
|
||||
"reset_settings": "Réinitialiser les paramètres",
|
||||
"reset_settings_confirm": "Oui, réinitialiser",
|
||||
"response_details": "Détails de la réponse",
|
||||
"response_time": "Temps de réponse",
|
||||
"result": "Résultat",
|
||||
"rewrite_add": "Ajouter une réécriture DNS",
|
||||
"rewrite_edit": "Modifier la réécriture DNS",
|
||||
"rewrite_hosts_applied": "Réécrit par la règle du fichier hosts",
|
||||
"rewrite_settings_updated": "Les paramètres de réécriture DNS ont été mis à jour avec succès",
|
||||
"rewrite_updated": "Réécriture DNS mise à jour",
|
||||
"rewritten": "Réécrit",
|
||||
"rules_label": "Règles",
|
||||
"safe_browsing": "Navigation sécurisée",
|
||||
"safe_search": "Recherche sécurisée",
|
||||
"saturday": "Samedi",
|
||||
"save": "Sauvegarder",
|
||||
"save_btn": "Sauvegarder",
|
||||
"search_placeholder": "Rechercher",
|
||||
"server_config_blocking_mode": "Mode du blocage",
|
||||
"server_config_blocking_mode_ipv4": "Blocage IPv4",
|
||||
"server_config_blocking_mode_ipv6": "Blocage IPv6",
|
||||
"server_config_custom_ip": "Adresse IP personnalisée",
|
||||
"server_config_default": "Défaut",
|
||||
"server_config_disable_ipv6": "Désactiver la résolution des adresses IPv6",
|
||||
"server_config_dnssec_enable": "Activer DNSSEC",
|
||||
"server_config_edns_enable": "Activer le sous-réseau du client EDNS",
|
||||
"server_config_nxdomain": "NXDOMAIN",
|
||||
"server_config_rate_limit_placeholder": "Entrez la limite de taux",
|
||||
"server_config_rate_limit_whitelist_faq": "Adresses IP exclues de la limitation du débit",
|
||||
"server_config_refused": "REFUSED",
|
||||
"server_config_subnet_len_ipv4": "Longueur du préfixe de sous-réseau pour les adresses IPv4",
|
||||
"server_config_subnet_len_ipv4_faq": "Longueur du préfixe de sous-réseau pour les adresses IPv4 utilisé pour la limitation de vitesse. La valeur par défaut est 24",
|
||||
"server_config_subnet_len_ipv6": "Longueur du préfixe de sous-réseau pour les adresses IPv6",
|
||||
"server_config_subnet_len_ipv6_faq": "Longueur du préfixe de sous-réseau pour les adresses IPv6 utilisé pour la limitation de débit. La valeur par défaut est 56",
|
||||
"set_static_ip": "Définir une adresse IP statique",
|
||||
"settings": "Paramètres",
|
||||
"settings_browsing_security": "Navigation sécurisée",
|
||||
"settings_confirm_clear_statistics": "Effacer les statistiques?",
|
||||
"settings_custom": "Personnalisé",
|
||||
"settings_general_short": "General",
|
||||
"settings_global": "Général",
|
||||
"settings_notify_changes_saved": "Modifications enregistrées",
|
||||
"settings_notify_statistics_cleared": "Statistiques effacées",
|
||||
"settings_parental_control": "Contrôle parental",
|
||||
"settings_rule_syntax": "Syntaxe des règles",
|
||||
"settings_safe_search": "Recherche sécurisée",
|
||||
"settings_statistics": "Statistiques",
|
||||
"settings_statistics_clear": "Réinitialiser les statistiques",
|
||||
"settings_statistics_retention": "Maintien des statistiques",
|
||||
"settings_yes_clear": "Oui, effacer",
|
||||
"setup_devices_router_title": "Routeur",
|
||||
"setup_dns_privacy_ioc_mac": "Configuration sur iOS et macOS",
|
||||
"setup_dns_privacy_other_title": "Autres implémentations",
|
||||
"show_blocked_responses": "Bloqué",
|
||||
"show_processed_responses": "Traités",
|
||||
"source_label": "Source",
|
||||
"source_tooltip": "Source",
|
||||
"stats_adult": "Sites à contenu adulte bloqués",
|
||||
"stats_query_domain": "Domaines les plus recherchés",
|
||||
"status_table_header": "État",
|
||||
"subnet_error": "Les adresses doivent être dans le même sous-réseau",
|
||||
"sunday": "Dimanche",
|
||||
"system_host_files": "Fichier d'hôtes système",
|
||||
"table_client": "Client",
|
||||
"tags_title": "Mots clés",
|
||||
"test_upstreams": "Tester les upstreams",
|
||||
"threats_blocked": "Menaces bloquées",
|
||||
"thursday": "Jeudi",
|
||||
"time_table_header": "Temps",
|
||||
"top_blocked_domains": "Les domaines les plus fréquemment bloqués",
|
||||
"top_clients": "Meilleurs clients",
|
||||
"top_upstreams": "Top amonts",
|
||||
"total": "Total",
|
||||
"try_again": "Essayer à nouveau",
|
||||
"tuesday": "Mardi",
|
||||
"unavailable_dhcp": "Le DHCP n’est pas disponible",
|
||||
"unavailable_dhcp_desc": "AdGuard Home ne peut pas exécuter un serveur DHCP sur votre système d’exploitation",
|
||||
"unblock": "Débloquer",
|
||||
"update_filters_disable": "Désactiver",
|
||||
"update_filters_interval_custom": "Personnalisé",
|
||||
"update_filters_interval_daily": "Tous les jours",
|
||||
"update_filters_interval_hourly": "Chaque heure",
|
||||
"update_filters_title": "Mettre à jour automatiquement les filtres",
|
||||
"updated_custom_filtering_toast": "Règles d'utilisateur enregistrées",
|
||||
"updated_upstream_dns_toast": "Serveurs en amont enregistrés",
|
||||
"updates_checked": "Une nouvelle version de AdGuard Home est disponible",
|
||||
"updates_version_equal": "AdGuard Home est à jour",
|
||||
"upstream": "Amont",
|
||||
"upstream_dns": "Serveurs DNS upstream",
|
||||
"upstream_dns_addresses": "Adresses du serveur, une par ligne",
|
||||
"upstream_dns_fastest_addr": "Adresse IP la plus rapide",
|
||||
"upstream_dns_load_balancing": "Équilibrage de charge",
|
||||
"upstream_dns_parallel_requests": "Requêtes en parallèle",
|
||||
"upstream_dns_servers_title": "Serveurs DNS upstream",
|
||||
"upstream_examples_title": "Exemples",
|
||||
"upstream_private_ptr_resolvers_title": "Utiliser des résolveurs DNS inversés privés",
|
||||
"upstreams": "En amont",
|
||||
"url_label": "URL",
|
||||
"use_saved_key": "Utiliser la clef précédemment enregistrée",
|
||||
"user_rules": "Règles d'utilisateur",
|
||||
"user_rules_add_to_allowlist": "Ajouter à la liste blanche",
|
||||
"user_rules_check_button": "Vérifier",
|
||||
"user_rules_check_client_placeholder": "Saisissez l'identifiant du client",
|
||||
"user_rules_check_hostname_label": "Nom d'hôte ou nom de domaine",
|
||||
"user_rules_disable_filter": "Désactiver le filtre",
|
||||
"user_rules_disable_parental_control": "Désactiver le contrôle parental",
|
||||
"user_rules_dns_record_type_placeholder": "Sélectionnez le type d'enregistrement DNS",
|
||||
"user_rules_domain_blocked": "Domaine bloqué",
|
||||
"user_rules_edit_dns_rewrite": "Modifier la réécriture DNS",
|
||||
"user_rules_rewrite_rule_is_applied": "Règle de réécriture appliquée",
|
||||
"user_rules_title": "Règles de filtrage d'utilisateur",
|
||||
"username_label": "Nom d'utilisateur",
|
||||
"username_placeholder": "Saisir un nom d'utilisateur",
|
||||
"validated_with_dnssec": "Validé avec DNSSEC",
|
||||
"wednesday": "Mercredi",
|
||||
"whois": "WHOIS",
|
||||
"yes": "Oui"
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
{
|
||||
"access_settings_title": "Impostazioni di accesso",
|
||||
"actions_label": "Azioni",
|
||||
"actions_table_header": "Azioni",
|
||||
"add": "Aggiungi",
|
||||
"add_allowlist": "Aggiungi lista bianca",
|
||||
"add_blocklist": "Aggiungi lista nera",
|
||||
"add_persistent_client": "Aggiungi come client persistente",
|
||||
"add_to_allowlist": "Aggiungi alla lista bianca",
|
||||
"ads_blocked": "Annunci bloccati",
|
||||
"ads_blocked_card": "Annunci bloccati",
|
||||
"adult_websites_blocked_tooltip": "Numero di siti web per adulti bloccati",
|
||||
"all_lists_up_to_date_toast": "Tutti gli elenchi sono aggiornati",
|
||||
"allowed": "Consentito",
|
||||
"allowlist_add": "Aggiungi lista bianca",
|
||||
"allowlist_edit": "Modifica lista bianca",
|
||||
"apply": "Applica",
|
||||
"auto_clients_title": "Client in tempo reale",
|
||||
"autofix_warning_result": "Di conseguenza, tutte le richieste DNS dal sistema verranno elaborate da AdGuardHome per impostazione predefinita.",
|
||||
"autofix_warning_text": "Se fai clic su \"Correggi\", AdGuardHome configurerà il tuo sistema per utilizzare il server DNS AdGuardHome.",
|
||||
"average_time_processing": "Tempo di elaborazione medio",
|
||||
"average_upstream_response_time": "Tempo medio di risposta upstream",
|
||||
"back": "Indietro",
|
||||
"block": "Blocca",
|
||||
"block_for_this_client_only": "Blocca solo per questo client",
|
||||
"blocked": "Bloccato",
|
||||
"blocked_by_cname_or_ip": "Bloccato da CNAME o IP",
|
||||
"blocked_service": "Servizio bloccato",
|
||||
"blocked_services": "Servizi bloccati",
|
||||
"blocked_threats": "Minacce bloccate",
|
||||
"blocked_threats_chart": "Minacce bloccate",
|
||||
"blocklist_add_manual": "Aggiungi manualmente",
|
||||
"blocklist_edit": "Modifica lista nera",
|
||||
"blocklist_homepage": "Pagina principale",
|
||||
"blocklist_url_file_path": "URL o percorso del file",
|
||||
"blocklists_add": "Aggiungi lista nera",
|
||||
"bootstrap_dns": "Server DNS bootstrap",
|
||||
"cache_cleared": "Cache DNS è stata cancellata correttamente",
|
||||
"cache_config_clear": "Cancella cache",
|
||||
"cache_config_optimistic": "Optimistic caching",
|
||||
"cache_config_ttl_validation": "La sovrascrittura del valore TTL minimo della cache deve essere inferiore o uguale a quello massimo",
|
||||
"cache_enabled": "Abilita la cache",
|
||||
"cancel": "Annulla",
|
||||
"cancel_btn": "Annulla",
|
||||
"category_tooltip": "Categoria",
|
||||
"changes_saved_success": "Modifiche salvate",
|
||||
"check_dhcp_servers": "Controlla la presenza di server DHCP",
|
||||
"check_not_found": "Non trovato negli elenchi dei filtri",
|
||||
"check_updates_btn": "Ricerca aggiornamenti",
|
||||
"clear_btn": "Cancellare",
|
||||
"client_add": "Aggiungi Client",
|
||||
"client_details": "Dettagli client",
|
||||
"client_id": "ClientID",
|
||||
"client_id_placeholder": "Inserisci un ClientID",
|
||||
"client_identifier": "Identificatore",
|
||||
"client_settings": "Impostazioni client",
|
||||
"client_table_header": "Client",
|
||||
"clients_add_identifier": "Aggiungi identificatore",
|
||||
"clients_dns_cache_size": "Dimensioni cache DNS (in byte)",
|
||||
"clients_not_found": "Nessun client trovato",
|
||||
"clients_protection": "Protezione",
|
||||
"clients_tags": "Tag",
|
||||
"clients_title": "Client persistenti",
|
||||
"clients_use_global_settings": "Utilizza le impostazioni globali",
|
||||
"close": "Chiudi",
|
||||
"confirm": "Conferma",
|
||||
"copied": "Copiato negli appunti",
|
||||
"copy": "Copia",
|
||||
"custom_filter_rules": "Regole filtri personalizzate",
|
||||
"custom_filtering_rules": "Regole filtri personalizzate",
|
||||
"dark_theme": "Tema scuro",
|
||||
"dashboard": "Cruscotto",
|
||||
"delete_btn": "Elimina",
|
||||
"delete_table_action": "Elimina",
|
||||
"dhcp_add_static_lease": "Aggiungi lease statico",
|
||||
"dhcp_config_saved": "Salvataggio configurazione server DHCP riuscito",
|
||||
"dhcp_edit_static_lease": "Modifica locazione statica",
|
||||
"dhcp_form_gateway_input": "IP Gateway",
|
||||
"dhcp_form_lease_title": "Tempo di lease DHCP (in secondi)",
|
||||
"dhcp_form_subnet_input": "Maschera di sottorete",
|
||||
"dhcp_hardware_address": "Indirizzo hardware",
|
||||
"dhcp_interface_select": "Seleziona l'interfaccia DHCP",
|
||||
"dhcp_ip_addresses": "Indirizzi IP",
|
||||
"dhcp_leases_not_found": "Nessun lease DHCP trovato",
|
||||
"dhcp_new_static_lease": "Nuovo lease statico",
|
||||
"dhcp_reset_leases": "Reimposta tutti i temporanei",
|
||||
"dhcp_reset_leases_success": "DHCP temporanei reimpostati correttamente",
|
||||
"dhcp_settings": "Impostazioni DHCP",
|
||||
"dhcp_static_leases_not_found": "Non è stato trovato nessun leases statico DHCP",
|
||||
"dhcp_table_hostname": "Nome host",
|
||||
"dhcp_table_ip_address": "Indirizzo IP",
|
||||
"dhcp_warning": "Se desideri attivare il server DHCP integrato, assicurati che non vi siano altri server DHCP attivi, ciò potrebbe causare problemi di connessione alla rete per i dispositivi collegati!",
|
||||
"disable": "Disattiva",
|
||||
"disable_notify_until_tomorrow": "Disattiva la protezione fino a domani",
|
||||
"disable_protection_btn": "Disattiva protezione",
|
||||
"disabled_dhcp": "Server DHCP disattivato",
|
||||
"disabled_protection": "Protezione disattivata",
|
||||
"disallow_this_client": "Blocca questo client",
|
||||
"dns_cache_config": "Configurazione cache DNS",
|
||||
"dns_config": "Configurazione server DNS",
|
||||
"dns_over_https": "DNS su HTTPS",
|
||||
"dns_over_quic": "DNS su QUIC",
|
||||
"dns_over_tls": "DNS su TLS",
|
||||
"dns_privacy": "Privacy DNS",
|
||||
"dns_query": "Richieste DNS",
|
||||
"dns_rewrites": "Riscrittura DNS",
|
||||
"dns_settings": "Impostazioni DNS",
|
||||
"dns_test_ok_toast": "I server DNS specificati funzionano correttamente",
|
||||
"domain": "Dominio",
|
||||
"domain_or_client": "Dominio o client",
|
||||
"down": "Spenta",
|
||||
"download_mobileconfig": "Scarica file di configurazione",
|
||||
"edit_table_action": "Modifica",
|
||||
"enable": "Attiva",
|
||||
"enabled_dhcp": "Server DHCP attivo",
|
||||
"enabled_protection": "Protezione attiva",
|
||||
"enabled_table_header": "Attivo",
|
||||
"encryption_certificate_path": "Percorso di certificato",
|
||||
"encryption_certificates": "Certificati",
|
||||
"encryption_certificates_input": "Copia / incolla qui i certificati codificati PEM.",
|
||||
"encryption_certificates_source_content": "Incolla i contenuti di certificato",
|
||||
"encryption_certificates_source_path": "Definisci un percorso alle file dei certificati",
|
||||
"encryption_chain_invalid": "La catena di certificati non è valida",
|
||||
"encryption_chain_valid": "La catena di certificati è valida",
|
||||
"encryption_config_saved": "Configurazione crittografia salvata",
|
||||
"encryption_doq": "Porta DNS su QUIC",
|
||||
"encryption_dot": "DNS su porta TLS",
|
||||
"encryption_https": "Porta HTTPS",
|
||||
"encryption_key": "Chiave privata",
|
||||
"encryption_key_input": "Copia/Incolla qui la tua chiave privata codificata PEM per il tuo certificato.",
|
||||
"encryption_key_source_content": "Incolla i contenuti della chiave privata",
|
||||
"encryption_plain_dns": "DNS semplice",
|
||||
"encryption_plain_dns_error": "Per disabilitare il DNS semplice, abilitare almeno un protocollo DNS crittografato",
|
||||
"encryption_private_key_path": "Percorso della chiave privata",
|
||||
"encryption_server": "Nome server",
|
||||
"encryption_server_enter": "Inserisci il tuo nome di dominio",
|
||||
"encryption_title": "crittografia",
|
||||
"enter_cache_size": "Immetti dimensioni cache (in byte)",
|
||||
"enter_ip_address_placeholder": "Inserire il indirizzo IP",
|
||||
"error": "Errore",
|
||||
"fallback_dns": "Server DNS di fallback",
|
||||
"filter_category_general": "General",
|
||||
"filter_category_general_desc": "Liste che bloccano tracciatori e inserzioni nella maggioranza dei dispositivi",
|
||||
"filter_category_other": "Altro",
|
||||
"filter_category_other_desc": "Altre liste nere",
|
||||
"filter_category_regional": "Regionale",
|
||||
"filter_category_regional_desc": "Elenchi focalizzati su annunci regionali e server tracciatori",
|
||||
"filter_category_security": "Sicurezza",
|
||||
"filter_category_security_desc": "Elenchi progettati specificamente per bloccare domini malevoli, di phishing o truffa",
|
||||
"filter_removed_successfully": "L'elenco è stata correttamente rimosso",
|
||||
"filters": "Filtri",
|
||||
"forgot_password": "Hai perso la password?",
|
||||
"form_enter_blocked_response_ttl": "Inserisci tempo di vita (TTL) della risposta bloccata (secondi)",
|
||||
"form_error_answer_format": "Formato di risposta non valido",
|
||||
"form_error_client_id_format": "Il ClientID deve contenere solo numeri, lettere minuscole, e trattini",
|
||||
"form_error_domain_format": "Formato del dominio non valido",
|
||||
"form_error_equal": "Non deve essere uguale",
|
||||
"form_error_gateway_ip": "Il leasing non può avere l'indirizzo IP del gateway",
|
||||
"form_error_ip4_format": "Indirizzo IPv4 non valido",
|
||||
"form_error_ip6_format": "Indirizzo IPv6 non valido",
|
||||
"form_error_ip_format": "Indirizzo IP non valido",
|
||||
"form_error_mac_format": "Indirizzo MAC non valido",
|
||||
"form_error_password": "Password non corrispondente",
|
||||
"form_error_port": "Immettere un valore di porta valido",
|
||||
"form_error_port_range": "Immettere il valore della porta nell'intervallo 80-65535",
|
||||
"form_error_port_unsafe": "Questa porta non è sicura",
|
||||
"form_error_required": "Completare questo campo",
|
||||
"form_error_server_name": "Nome server non valido",
|
||||
"form_error_url_or_path_format": "URL o percorso assoluto dell'elenco non validi",
|
||||
"friday": "Venerdì",
|
||||
"gateway_or_subnet_invalid": "Maschera di sottorete non valida",
|
||||
"general_settings": "Impostazioni generali",
|
||||
"general_statistics": "Statistiche generali",
|
||||
"greater_range_start_error": "Deve essere maggiore dell'intervallo di inizio",
|
||||
"ignore_domains_desc_query": "Le richieste che corrispondono a queste regole non vengono scritte nel registro delle richieste",
|
||||
"ignore_domains_title": "Domini ignorati",
|
||||
"inactivity_schedule_add": "Aggiungere programma",
|
||||
"inactivity_schedule_edit": "Modificare programma",
|
||||
"install_auth_confirm": "Conferma password",
|
||||
"install_auth_password": "Password",
|
||||
"install_auth_password_enter": "Inserisci password",
|
||||
"install_auth_username": "Nome utente",
|
||||
"install_auth_username_enter": "Inserisci nome utente",
|
||||
"install_saved": "Salvataggio riuscito",
|
||||
"install_settings_all_interfaces": "Tutte le interfacce",
|
||||
"install_settings_port": "Porta",
|
||||
"install_static_error": "AdGuard Home non può configurarlo automaticamente per questa interfaccia di rete. Ti suggeriamo di cercare un metodo alternativo per effettuare tale operazione manualmente.",
|
||||
"install_static_ok": "Buone notizie! L'indirizzo IP statico è già configurato",
|
||||
"install_step": "Passo",
|
||||
"install_submit_title": "Felicitazioni!",
|
||||
"ip_address": "Indirizzo IP",
|
||||
"known_tracker": "Tracciatore noto",
|
||||
"light_theme": "Tema chiaro",
|
||||
"login": "Accedi",
|
||||
"logout": "Disconnetti",
|
||||
"make_static": "Rendere statico",
|
||||
"milliseconds_abbreviation": "ms",
|
||||
"monday": "Lunedi",
|
||||
"name": "Nome",
|
||||
"name_label": "Nome",
|
||||
"name_table_header": "Nome",
|
||||
"name_tooltip": "Nome",
|
||||
"next": "Avanti",
|
||||
"no": "No",
|
||||
"nothing_found": "Non è stato trovato nulla",
|
||||
"notify_undo": "Annulla",
|
||||
"notify_updated": "Aggiornato",
|
||||
"parental_control": "Controllo Parentale",
|
||||
"parental_group_cdn": "CDN",
|
||||
"parental_group_development": "Sviluppo di programmi",
|
||||
"parental_group_gaming": "Giochi",
|
||||
"parental_group_hosting": "Hosting web",
|
||||
"parental_group_privacy": "Strumenti per riservatezza",
|
||||
"parental_group_shopping": "Shopping",
|
||||
"parental_group_social": "Reti social",
|
||||
"parental_group_social_network": "Reti social",
|
||||
"parental_group_software": "Sviluppo di programmi",
|
||||
"parental_group_streaming": "Streaming",
|
||||
"password_label": "Password",
|
||||
"password_placeholder": "Inserisci password",
|
||||
"plain_dns": "DNS semplice",
|
||||
"privacy_policy": "Politica sulla Riservatezza",
|
||||
"protection": "Protezione",
|
||||
"protocol": "Protocollo",
|
||||
"query_log_allowed": "Consentito",
|
||||
"query_log_blocked": "Bloccato",
|
||||
"query_log_blocked_services": "Servizi bloccati",
|
||||
"query_log_blocked_threats": "Minacce bloccate",
|
||||
"query_log_custom_filtering_rules": "Regole filtri personalizzate",
|
||||
"query_log_processed": "Elaborato",
|
||||
"query_log_retention": "Rotazione dei registri richieste",
|
||||
"query_log_rewritten": "Riscritto",
|
||||
"query_log_safe_search": "Ricerca sicura",
|
||||
"query_log_strict_search": "Utilizzare le doppie virgolette per una ricerca precisa",
|
||||
"rate_limit_subnet_len_ipv4_error": "La lunghezza del prefisso di sottorete IPv4 deve essere compresa tra 0 e 32",
|
||||
"rate_limit_subnet_len_ipv6_error": "La lunghezza del prefisso di sottorete IPv6 deve essere compresa tra 0 e 128",
|
||||
"refresh_btn": "Aggiorna",
|
||||
"refresh_statics": "Aggiorna statistiche",
|
||||
"release_notes": "Note di rilascio",
|
||||
"remove": "Rimuovi",
|
||||
"report_an_issue": "Segnala un problema",
|
||||
"request_table_header": "Richiesta",
|
||||
"requests_table_header": "Richieste",
|
||||
"reset": "Reimposta",
|
||||
"reset_settings": "Reimposta impostazioni",
|
||||
"reset_settings_confirm": "Sì, ripristina",
|
||||
"response_details": "Dettagli di Risposta",
|
||||
"response_time": "Tempo di risposta",
|
||||
"result": "Risultato",
|
||||
"rewrite_add": "Aggiungi la riscrittura DNS",
|
||||
"rewrite_edit": "Modifica della riscrittura DNS",
|
||||
"rewrite_hosts_applied": "Riscritto dal file delle regole host",
|
||||
"rewrite_settings_updated": "Impostazioni di riscrittura DNS aggiornate correttamente",
|
||||
"rewrite_updated": "Riscrittura DNS aggiornata correttamente",
|
||||
"rewritten": "Riscritto",
|
||||
"rules_label": "Regole",
|
||||
"safe_browsing": "Navigazione Sicura",
|
||||
"safe_search": "Ricerca sicura",
|
||||
"saturday": "Sabato",
|
||||
"save": "Salva",
|
||||
"save_btn": "Salva",
|
||||
"search_placeholder": "Cerca",
|
||||
"server_config_blocking_mode": "Modalità di blocco",
|
||||
"server_config_blocking_mode_ipv4": "Blocca IPv4",
|
||||
"server_config_blocking_mode_ipv6": "Blocca IPv6",
|
||||
"server_config_custom_ip": "Indirizzo IP personalizzato",
|
||||
"server_config_default": "Predefinito",
|
||||
"server_config_disable_ipv6": "Disattiva risoluzione indirizzi IPv6",
|
||||
"server_config_dnssec_enable": "Attiva DNSSEC",
|
||||
"server_config_edns_enable": "Attiva client di sottorete EDNS",
|
||||
"server_config_nxdomain": "NXDOMAIN",
|
||||
"server_config_rate_limit_placeholder": "Imposta limite delle richieste",
|
||||
"server_config_rate_limit_whitelist_faq": "Indirizzi IP esclusi dalla limitazione della velocità",
|
||||
"server_config_refused": "REFUSED",
|
||||
"server_config_subnet_len_ipv4": "Lunghezza prefisso di sottorete per indirizzi IPv4",
|
||||
"server_config_subnet_len_ipv4_faq": "Lunghezza prefisso sottorete per indirizzi IPv4 usati per la limitazione della velocità. Valore predefinito 24",
|
||||
"server_config_subnet_len_ipv6": "Lunghezza prefisso di sottorete per indirizzi IPv6",
|
||||
"server_config_subnet_len_ipv6_faq": "Lunghezza prefisso di sottorete per indirizzi IPv6 usati per la limitazione della velocità. Valore predefinito 56",
|
||||
"set_static_ip": "Imposta un indirizzo IP statico",
|
||||
"settings": "Impostazioni",
|
||||
"settings_browsing_security": "Sicurezza di navigazione",
|
||||
"settings_confirm_clear_statistics": "Azzerare statistiche?",
|
||||
"settings_custom": "Personalizzato",
|
||||
"settings_general_short": "General",
|
||||
"settings_global": "Globale",
|
||||
"settings_notify_changes_saved": "Modifiche salvate",
|
||||
"settings_notify_statistics_cleared": "Statistiche azzerate",
|
||||
"settings_parental_control": "Controllo Parentale",
|
||||
"settings_rule_syntax": "Sintassi delle regole",
|
||||
"settings_safe_search": "Ricerca sicura",
|
||||
"settings_statistics": "Statistiche",
|
||||
"settings_statistics_clear": "Azzera statistiche",
|
||||
"settings_statistics_retention": "Conservazione delle statistiche",
|
||||
"settings_yes_clear": "Sì, cancella",
|
||||
"setup_devices_router_title": "Router",
|
||||
"setup_dns_privacy_ioc_mac": "configurazione iOS e macOS",
|
||||
"setup_dns_privacy_other_title": "Altre implementazion",
|
||||
"show_blocked_responses": "Bloccato",
|
||||
"show_processed_responses": "Elaborato",
|
||||
"source_label": "Fonte",
|
||||
"source_tooltip": "Fonte",
|
||||
"stats_adult": "Siti per adulti bloccati",
|
||||
"stats_query_domain": "Domini maggiormente richiesti",
|
||||
"status_table_header": "Stato",
|
||||
"subnet_error": "Gli indirizzi devono trovarsi in una sottorete",
|
||||
"sunday": "Domenica",
|
||||
"system_host_files": "File host di sistema",
|
||||
"table_client": "Client",
|
||||
"tags_title": "Tag",
|
||||
"test_upstreams": "Testa gli upstream",
|
||||
"threats_blocked": "Minacce bloccate",
|
||||
"thursday": "Giovedì",
|
||||
"time_table_header": "Ora",
|
||||
"top_blocked_domains": "Domini maggiormente bloccati",
|
||||
"top_clients": "Client più utilizzati",
|
||||
"top_upstreams": "Top upstream",
|
||||
"total": "Totale",
|
||||
"try_again": "Riprova",
|
||||
"tuesday": "Martedì",
|
||||
"unavailable_dhcp": "DHCP non disponibile",
|
||||
"unavailable_dhcp_desc": "AdGuard Home non può eseguire un server DHCP sul tuo sistema operativo",
|
||||
"unblock": "Sblocca",
|
||||
"update_filters_disable": "Disattiva",
|
||||
"update_filters_interval_custom": "Personalizzato",
|
||||
"update_filters_interval_daily": "Ogni giorno",
|
||||
"update_filters_interval_hourly": "Ogni ora",
|
||||
"update_filters_title": "Aggiorna automaticamente i filtri",
|
||||
"updated_custom_filtering_toast": "Le regole personalizzate sono state correttamente salvate",
|
||||
"updated_upstream_dns_toast": "I server upstream sono stati salvati correttamente",
|
||||
"updates_checked": "Nuova versione di AdGuard Home è disponibile",
|
||||
"updates_version_equal": "AdGuard Home è aggiornato",
|
||||
"upstream": "Upstream",
|
||||
"upstream_dns": "Server DNS upstream",
|
||||
"upstream_dns_addresses": "Indirizzi server, uno per riga",
|
||||
"upstream_dns_fastest_addr": "Indirizzo IP più veloce",
|
||||
"upstream_dns_load_balancing": "Bilanciamento del carico",
|
||||
"upstream_dns_parallel_requests": "Richieste parallele",
|
||||
"upstream_dns_servers_title": "Server DNS upstream",
|
||||
"upstream_examples_title": "Esempi",
|
||||
"upstream_private_ptr_resolvers_title": "Utilizza dei resolver rDNS privati",
|
||||
"upstreams": "Upstream",
|
||||
"url_label": "URL",
|
||||
"use_saved_key": "Utilizza la chiave salvata in precedenza",
|
||||
"user_rules": "Regole utente",
|
||||
"user_rules_add_to_allowlist": "Aggiungi alla lista bianca",
|
||||
"user_rules_check_button": "Controlla",
|
||||
"user_rules_check_client_placeholder": "Inserisci identificatore client",
|
||||
"user_rules_check_hostname_label": "Nome host o nome di dominio",
|
||||
"user_rules_disable_filter": "Disattiva filtro",
|
||||
"user_rules_disable_parental_control": "Disattiva Controllo Parentale",
|
||||
"user_rules_dns_record_type_placeholder": "Seleziona il tipo di registrazione DNS",
|
||||
"user_rules_domain_blocked": "Il dominio è bloccato",
|
||||
"user_rules_edit_dns_rewrite": "Modifica della riscrittura DNS",
|
||||
"user_rules_rewrite_rule_is_applied": "Regola di riscrittura applicata",
|
||||
"user_rules_title": "Regole filtri personalizzate",
|
||||
"username_label": "Nome utente",
|
||||
"username_placeholder": "Inserisci nome utente",
|
||||
"validated_with_dnssec": "Verificato con DNSSEC",
|
||||
"wednesday": "Mercoledì",
|
||||
"whois": "WHOIS",
|
||||
"yes": "Sì"
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
{
|
||||
"access_settings_title": "アクセス設定",
|
||||
"actions_label": "操作",
|
||||
"actions_table_header": "操作",
|
||||
"add": "追加する",
|
||||
"add_allowlist": "許可リストに追加する",
|
||||
"add_blocklist": "ブロックリストに追加する",
|
||||
"add_persistent_client": "永続クライアントとして追加する",
|
||||
"add_to_allowlist": "ホワイトリストに追加する",
|
||||
"ads_blocked": "広告ブロック数",
|
||||
"ads_blocked_card": "広告ブロック数",
|
||||
"adult_websites_blocked_tooltip": "ブロックされたアダルトウェブサイトの数",
|
||||
"all_lists_up_to_date_toast": "すべてのリストは既に最新です",
|
||||
"allowed": "ホワイトリストに追加済",
|
||||
"allowlist_add": "許可リストに追加する",
|
||||
"allowlist_edit": "許可リストの編集",
|
||||
"apply": "適用する",
|
||||
"auto_clients_title": "ランタイムクライアント",
|
||||
"autofix_warning_result": "その結果、システムからのすべてのDNSリクエストは、デフォルトでAdGuard Homeによって処理されます。",
|
||||
"autofix_warning_text": "「修正」をクリックすると、AdGuardHomeはAdGuardHome DNSサーバを使用するようにシステムを構成します。",
|
||||
"average_time_processing": "平均処理時間",
|
||||
"average_upstream_response_time": "アップストリームの平均応答時間",
|
||||
"back": "戻る",
|
||||
"block": "ブロックする",
|
||||
"block_for_this_client_only": "このクライアントに対してのみブロックする",
|
||||
"blocked": "ブロック済",
|
||||
"blocked_by_cname_or_ip": "CNAMEもしくはIPアドレスによってブロック済み",
|
||||
"blocked_service": "ブロックするサービス",
|
||||
"blocked_services": "ブロックするサービス",
|
||||
"blocked_threats": "ブロック済み脅威数",
|
||||
"blocked_threats_chart": "ブロック済み脅威数",
|
||||
"blocklist_add_manual": "手動で追加する",
|
||||
"blocklist_edit": "ブロックリストの編集",
|
||||
"blocklist_homepage": "ホームページ",
|
||||
"blocklist_url_file_path": "URLまたはファイルパス",
|
||||
"blocklists_add": "ブロックリストに追加する",
|
||||
"bootstrap_dns": "ブートストラップDNSサーバ",
|
||||
"cache_cleared": "DNSキャッシュのクリア完了です。",
|
||||
"cache_config_clear": "キャッシュをクリアする",
|
||||
"cache_config_optimistic": "Optimistic cashing (オプティミスティック・キャッシュ)",
|
||||
"cache_config_ttl_validation": "最小キャッシュTTL上書き値は最大値以下にする必要があります",
|
||||
"cache_enabled": "キャッシュを有効にする",
|
||||
"cancel": "キャンセル ",
|
||||
"cancel_btn": "キャンセル ",
|
||||
"category_tooltip": "カテゴリ",
|
||||
"changes_saved_success": "変更内容が保存されました。",
|
||||
"check_dhcp_servers": "DHCPサーバをチェックする",
|
||||
"check_not_found": "フィルタ一覧には見つかりません",
|
||||
"check_updates_btn": "アップデートを確認する",
|
||||
"clear_btn": "クリア",
|
||||
"client_add": "クライアントを追加する",
|
||||
"client_details": "クライアントの詳細",
|
||||
"client_id": "ClientID(クライアントID)",
|
||||
"client_id_placeholder": "ClientIDを入力してください",
|
||||
"client_identifier": "識別子",
|
||||
"client_settings": "クライアント設定",
|
||||
"client_table_header": "クライアント",
|
||||
"clients_add_identifier": "識別子を追加する",
|
||||
"clients_dns_cache_size": "DNSキャッシュサイズ(バイト単位)",
|
||||
"clients_not_found": "クライアント情報はありません",
|
||||
"clients_protection": "AdGuardによる保護",
|
||||
"clients_tags": "タグ",
|
||||
"clients_title": "永速的クライアント",
|
||||
"clients_use_global_settings": "グローバル設定を使用する",
|
||||
"close": "閉じる",
|
||||
"confirm": "確認",
|
||||
"copied": "クリップボードにコピー済み",
|
||||
"copy": "コピー",
|
||||
"custom_filter_rules": "カスタム・フィルタリングルール",
|
||||
"custom_filtering_rules": "カスタム・フィルタリングルール",
|
||||
"dark_theme": "ダークテーマ",
|
||||
"dashboard": "ダッシュボード",
|
||||
"delete_btn": "削除する",
|
||||
"delete_table_action": "削除する",
|
||||
"dhcp_add_static_lease": "静的割り当てを追加する",
|
||||
"dhcp_config_saved": "DHCP構成が無事に保存されました。",
|
||||
"dhcp_edit_static_lease": "静的リースを編集",
|
||||
"dhcp_form_gateway_input": "ゲートウェイIP",
|
||||
"dhcp_form_lease_title": "DHCP割当時間(秒単位)",
|
||||
"dhcp_form_subnet_input": "サブネットマスク",
|
||||
"dhcp_hardware_address": "MACアドレス",
|
||||
"dhcp_interface_select": "DHCPインタフェースの選択",
|
||||
"dhcp_ip_addresses": "IPアドレス",
|
||||
"dhcp_leases_not_found": "DHCP割当はありません",
|
||||
"dhcp_new_static_lease": "新規静的割り当て",
|
||||
"dhcp_reset_leases": "すべてのリースをリセットする",
|
||||
"dhcp_reset_leases_success": "すべてのDHCPリース(割り当て)がリセット完了しました。",
|
||||
"dhcp_settings": "DHCP設定",
|
||||
"dhcp_static_leases_not_found": "DHCP静的割り当てはありません",
|
||||
"dhcp_table_hostname": "ホスト名",
|
||||
"dhcp_table_ip_address": "IPアドレス",
|
||||
"dhcp_warning": "ともかくDHCPサーバを有効にしたい場合は、ネットワーク内で他に稼働中のDHCPサーバがないことを確認してください。そうでなければ、ネットワーク上デバイスでインターネット接続を壊してしまう可能性があります!",
|
||||
"disable": "無効にする",
|
||||
"disable_notify_until_tomorrow": "明日まで保護を無効にする",
|
||||
"disable_protection_btn": "保護を無効にする",
|
||||
"disabled_dhcp": "DHCPサーバを無効にしました",
|
||||
"disabled_protection": "保護を無効にしました",
|
||||
"disallow_this_client": "このクライアントを拒否する",
|
||||
"dns_cache_config": "DNSキャッシュ設定",
|
||||
"dns_config": "DNSサーバ設定",
|
||||
"dns_over_https": "DNS-over-HTTPS",
|
||||
"dns_over_quic": "DNS-over-QUIC",
|
||||
"dns_over_tls": "DNS-over-TLS",
|
||||
"dns_privacy": "DNSプライバシー",
|
||||
"dns_query": "DNSクエリ",
|
||||
"dns_rewrites": "DNS書き換え",
|
||||
"dns_settings": "DNS設定",
|
||||
"dns_test_ok_toast": "指定されたDNSサーバは正しく動作しています",
|
||||
"domain": "ドメイン",
|
||||
"domain_or_client": "ドメインまたはクライアント",
|
||||
"down": "ダウン",
|
||||
"download_mobileconfig": "設定ファイルをダウンロードする",
|
||||
"edit_table_action": "編集",
|
||||
"enable": "有効にする",
|
||||
"enabled_dhcp": "DHCPサーバを有効にしました",
|
||||
"enabled_protection": "保護を有効にしました",
|
||||
"enabled_table_header": "本が有効",
|
||||
"encryption_certificate_path": "証明書のパスを入力してください",
|
||||
"encryption_certificates": "証明書",
|
||||
"encryption_certificates_input": "ここにPEM形式の証明書をコピー/ペーストしてください。",
|
||||
"encryption_certificates_source_content": "証明書の内容をペーストする",
|
||||
"encryption_certificates_source_path": "証明書のパスを設定する",
|
||||
"encryption_chain_invalid": "証明書チェーンが無効です",
|
||||
"encryption_chain_valid": "証明書チェーンは有効です。",
|
||||
"encryption_config_saved": "暗号化構成が保存されました。",
|
||||
"encryption_doq": "DNS-over-QUIC ポート",
|
||||
"encryption_dot": "DNS-over-TLS ポート",
|
||||
"encryption_https": "HTTPS ポート",
|
||||
"encryption_key": "秘密鍵",
|
||||
"encryption_key_input": "ここに証明書のためのPEM形式の秘密鍵をコピー/ペーストしてください。",
|
||||
"encryption_key_source_content": "秘密鍵の内容をペーストする",
|
||||
"encryption_plain_dns": "通常のDNS",
|
||||
"encryption_plain_dns_error": "プレーンDNSを無効にするには、暗号化DNSプロトコルを少なくとも 1 つ有効にしてください",
|
||||
"encryption_private_key_path": "秘密鍵のパスを入力してください",
|
||||
"encryption_server": "サーバー名",
|
||||
"encryption_server_enter": "ドメイン名を入力してください",
|
||||
"encryption_title": "暗号化",
|
||||
"enter_cache_size": "キャッシュサイズ(バイト単位)を入力してください",
|
||||
"enter_ip_address_placeholder": "IPアドレスを入力してください",
|
||||
"error": "エラー",
|
||||
"fallback_dns": "フォールバックDNSサーバー",
|
||||
"filter_category_general": "General",
|
||||
"filter_category_general_desc": "ほとんどのデバイスにて追跡と広告をブロックするリストです。",
|
||||
"filter_category_other": "その他",
|
||||
"filter_category_other_desc": "その他のブロックリストです。",
|
||||
"filter_category_regional": "地域別",
|
||||
"filter_category_regional_desc": "それぞれの地域の広告と追跡サーバをターゲットするリストです。",
|
||||
"filter_category_security": "マルウェアやフィッシング詐欺をブロック",
|
||||
"filter_category_security_desc": "マルウェア、フィッシング、詐欺ドメインをブロックするために設計された専用リストです。",
|
||||
"filter_removed_successfully": "リストの削除に成功しました。",
|
||||
"filters": "フィルタ",
|
||||
"forgot_password": "パスワードをお忘れですか?",
|
||||
"form_enter_blocked_response_ttl": "ブロック済み応答のTTL(秒単位)を入力してください",
|
||||
"form_error_answer_format": "応答のフォーマットが無効です",
|
||||
"form_error_client_id_format": "ClientIDには、数字、小文字、ハイフン以外は使用できません",
|
||||
"form_error_domain_format": "ドメイン名のフォーマットが無効です",
|
||||
"form_error_equal": "同じ値であってはなりません",
|
||||
"form_error_gateway_ip": "リースはゲートウェイのIPアドレスになっていることができません",
|
||||
"form_error_ip4_format": "IPv4アドレスが無効です",
|
||||
"form_error_ip6_format": "IPv6アドレスが無効です",
|
||||
"form_error_ip_format": "IPアドレスが無効です",
|
||||
"form_error_mac_format": "MACアドレスが無効です",
|
||||
"form_error_password": "パスワードが一致しません",
|
||||
"form_error_port": "有効なポート番号を入力してください",
|
||||
"form_error_port_range": "80〜65535 の範囲内でポート番号を入力してください",
|
||||
"form_error_port_unsafe": "これは不安全なポートです",
|
||||
"form_error_required": "この欄に記入してください",
|
||||
"form_error_server_name": "サーバー名が無効です",
|
||||
"form_error_url_or_path_format": "リストのURLまたは絶対パスが無効です",
|
||||
"friday": "金曜日",
|
||||
"gateway_or_subnet_invalid": "サブネットマスクが無効です",
|
||||
"general_settings": "一般設定",
|
||||
"general_statistics": "全般的な統計",
|
||||
"greater_range_start_error": "範囲開始値より大きい値でなければなりません",
|
||||
"ignore_domains_desc_query": "これらのルールに一致するクエリはクエリログに書き込まれません。",
|
||||
"ignore_domains_title": "無視するドメイン",
|
||||
"inactivity_schedule_add": "スケジュールを追加する",
|
||||
"inactivity_schedule_edit": "スケジュールを編集する",
|
||||
"install_auth_confirm": "パスワードの確認",
|
||||
"install_auth_password": "パスワード",
|
||||
"install_auth_password_enter": "パスワードを入力してください",
|
||||
"install_auth_username": "ユーザー名",
|
||||
"install_auth_username_enter": "ユーザ名を入力してください",
|
||||
"install_saved": "保存に成功しました",
|
||||
"install_settings_all_interfaces": "すべてのインターフェイス",
|
||||
"install_settings_port": "ポート",
|
||||
"install_static_error": "AdGuard Homeは、このネットワークインターフェースを自動構成することはできません。手動で行う方法に関する取扱説明書を探してください。",
|
||||
"install_static_ok": "良いニュースです! 既に静的IPアドレスで構成されています",
|
||||
"install_step": "手順",
|
||||
"install_submit_title": "おめでとうございます!",
|
||||
"ip_address": "IPアドレス",
|
||||
"known_tracker": "既知のトラッカー",
|
||||
"light_theme": "ライトテーマ",
|
||||
"login": "ログイン",
|
||||
"logout": "ログアウト",
|
||||
"make_static": "静的(static)にする",
|
||||
"milliseconds_abbreviation": "ms",
|
||||
"monday": "月曜日",
|
||||
"name": "フィルタ名",
|
||||
"name_label": "フィルタ名",
|
||||
"name_table_header": "フィルタ名",
|
||||
"name_tooltip": "フィルタ名",
|
||||
"next": "次へ",
|
||||
"no": "No",
|
||||
"nothing_found": "何も見つかりません",
|
||||
"notify_undo": "元に戻す",
|
||||
"notify_updated": "最終更新",
|
||||
"parental_control": "ペアレンタルコントロール",
|
||||
"parental_group_cdn": "CDN",
|
||||
"parental_group_development": "ソフトウェア開発",
|
||||
"parental_group_gaming": "ゲーム",
|
||||
"parental_group_hosting": "ウェブホスティング",
|
||||
"parental_group_privacy": "プライバシーツール",
|
||||
"parental_group_shopping": "ショッピング",
|
||||
"parental_group_social": "SNS",
|
||||
"parental_group_social_network": "SNS",
|
||||
"parental_group_software": "ソフトウェア開発",
|
||||
"parental_group_streaming": "ストリーミングサービス対応",
|
||||
"password_label": "パスワード",
|
||||
"password_placeholder": "パスワードを入力してください",
|
||||
"plain_dns": "通常のDNS",
|
||||
"privacy_policy": "プライバシーポリシー",
|
||||
"protection": "AdGuardによる保護",
|
||||
"protocol": "暗号化プロトコル",
|
||||
"query_log_allowed": "ホワイトリストに追加済",
|
||||
"query_log_blocked": "ブロック済",
|
||||
"query_log_blocked_services": "ブロックするサービス",
|
||||
"query_log_blocked_threats": "ブロック済み脅威数",
|
||||
"query_log_custom_filtering_rules": "カスタム・フィルタリングルール",
|
||||
"query_log_processed": "処理済み",
|
||||
"query_log_retention": "クエリ・ログのローテーション",
|
||||
"query_log_rewritten": "書換",
|
||||
"query_log_safe_search": "セーフサーチ",
|
||||
"query_log_strict_search": "完全一致検索には二重引用符を使用します",
|
||||
"rate_limit_subnet_len_ipv4_error": "IPv4 サブネットプレフィックス長は0〜32の範囲内である必要があります。",
|
||||
"rate_limit_subnet_len_ipv6_error": "IPv6 サブネットのプレフィックス長は0〜128の範囲内である必要があります。",
|
||||
"refresh_btn": "最新にする",
|
||||
"refresh_statics": "統計データを最新にする",
|
||||
"release_notes": "リリースノート",
|
||||
"remove": "削除",
|
||||
"report_an_issue": "問題を報告する",
|
||||
"request_table_header": "リクエスト",
|
||||
"requests_table_header": "リクエスト数",
|
||||
"reset": "初期化",
|
||||
"reset_settings": "設定をリセットする",
|
||||
"reset_settings_confirm": "リセット",
|
||||
"response_details": "応答の詳細",
|
||||
"response_time": "応答時間",
|
||||
"result": "結果",
|
||||
"rewrite_add": "DNS書き換え情報を追加する",
|
||||
"rewrite_edit": "DNS rewrite を編集する",
|
||||
"rewrite_hosts_applied": "hostsファイルのルールによって書き換え済み",
|
||||
"rewrite_settings_updated": "DNS rewrite設定の更新が正常に完了しました。",
|
||||
"rewrite_updated": "DNS rewrite を更新完了しました。",
|
||||
"rewritten": "書換",
|
||||
"rules_label": "ルール",
|
||||
"safe_browsing": "セーフブラウジング",
|
||||
"safe_search": "セーフサーチ",
|
||||
"saturday": "土曜日",
|
||||
"save": "保存",
|
||||
"save_btn": "保存",
|
||||
"search_placeholder": "検索",
|
||||
"server_config_blocking_mode": "ブロックモード",
|
||||
"server_config_blocking_mode_ipv4": "ブロック中のIPv4",
|
||||
"server_config_blocking_mode_ipv6": "ブロック中のIPv6",
|
||||
"server_config_custom_ip": "カスタムIPアドレス",
|
||||
"server_config_default": "デフォルト",
|
||||
"server_config_disable_ipv6": "IPv6アドレスの解決を無効にする",
|
||||
"server_config_dnssec_enable": "DNSSECを有効にする",
|
||||
"server_config_edns_enable": "EDNSクライアントサブネットを有効にする",
|
||||
"server_config_nxdomain": "NXDOMAIN",
|
||||
"server_config_rate_limit_placeholder": "頻度制限を入力してください",
|
||||
"server_config_rate_limit_whitelist_faq": "rate limiting(レート制限)の対象から外すIPアドレスを指定できます。",
|
||||
"server_config_refused": "REFUSED",
|
||||
"server_config_subnet_len_ipv4": "IPv4 アドレスのサブネットプレフィックス長",
|
||||
"server_config_subnet_len_ipv4_faq": "rate limiting(レート制限)に使用される IPv4 アドレスのサブネットプレフィックス長です。デフォルト値は 24 です。",
|
||||
"server_config_subnet_len_ipv6": "IPv6 アドレスのサブネットプレフィックス長",
|
||||
"server_config_subnet_len_ipv6_faq": "rate limiting(レート制限)に使用される IPv6 アドレスのサブネットプレフィックス長です。デフォルト値は 56 です。",
|
||||
"set_static_ip": "静的IPアドレスを設定する",
|
||||
"settings": "設定",
|
||||
"settings_browsing_security": "ブラウジング・セキュリティ",
|
||||
"settings_confirm_clear_statistics": "統計をクリア(初期化)しますか?",
|
||||
"settings_custom": "カスタム",
|
||||
"settings_general_short": "General",
|
||||
"settings_global": "グローバル",
|
||||
"settings_notify_changes_saved": "変更内容が保存されました。",
|
||||
"settings_notify_statistics_cleared": "統計がクリアされました。",
|
||||
"settings_parental_control": "ペアレンタルコントロール",
|
||||
"settings_rule_syntax": "ルールの構文",
|
||||
"settings_safe_search": "セーフサーチ",
|
||||
"settings_statistics": "統計",
|
||||
"settings_statistics_clear": "統計をクリア",
|
||||
"settings_statistics_retention": "統計保持",
|
||||
"settings_yes_clear": "クリアする",
|
||||
"setup_devices_router_title": "ルータ",
|
||||
"setup_dns_privacy_ioc_mac": "iOS と macOS での設定",
|
||||
"setup_dns_privacy_other_title": "その他の機能",
|
||||
"show_blocked_responses": "ブロック済",
|
||||
"show_processed_responses": "処理済み",
|
||||
"source_label": "ソース",
|
||||
"source_tooltip": "ソース",
|
||||
"stats_adult": "ブロックされたアダルトウェブサイト",
|
||||
"stats_query_domain": "最も問合せされたドメイン",
|
||||
"status_table_header": "ステータス",
|
||||
"subnet_error": "両アドレスが同じサブネット内にある必要があります",
|
||||
"sunday": "日曜日",
|
||||
"system_host_files": "システムのhostsファイル",
|
||||
"table_client": "クライアント",
|
||||
"tags_title": "タグ",
|
||||
"test_upstreams": "アップストリームをテストする",
|
||||
"threats_blocked": "脅威ブロック数",
|
||||
"thursday": "木曜日",
|
||||
"time_table_header": "時刻",
|
||||
"top_blocked_domains": "最もブロックされたドメイン",
|
||||
"top_clients": "トップクライアント",
|
||||
"top_upstreams": "上位のアップストリーム",
|
||||
"total": "合計",
|
||||
"try_again": "再試行する",
|
||||
"tuesday": "火曜日",
|
||||
"unavailable_dhcp": "DHCPは利用できません",
|
||||
"unavailable_dhcp_desc": "AdGuard Homeはお使いのOS上でDHCPサーバを実行できません。",
|
||||
"unblock": "ブロックを解除する",
|
||||
"update_filters_disable": "無効にする",
|
||||
"update_filters_interval_custom": "カスタム",
|
||||
"update_filters_interval_daily": "毎日",
|
||||
"update_filters_interval_hourly": "毎時",
|
||||
"update_filters_title": "フィルタを自動更新する",
|
||||
"updated_custom_filtering_toast": "カスタムルールを保存しました。",
|
||||
"updated_upstream_dns_toast": "アップストリームサーバーを保存しました。",
|
||||
"updates_checked": "AdGuard Homeの新バージョンが利用可能です。",
|
||||
"updates_version_equal": "AdGuard Homeは既に最新です",
|
||||
"upstream": "アップストリーム",
|
||||
"upstream_dns": "アップストリームDNSサーバー",
|
||||
"upstream_dns_addresses": "サーバーアドレス(1行に1つ):",
|
||||
"upstream_dns_fastest_addr": "最速のIPアドレス",
|
||||
"upstream_dns_load_balancing": "ロードバランシング",
|
||||
"upstream_dns_parallel_requests": "並列リクエスト",
|
||||
"upstream_dns_servers_title": "アップストリームDNSサーバー",
|
||||
"upstream_examples_title": "例",
|
||||
"upstream_private_ptr_resolvers_title": "プライベートリバースDNSリゾルバを使用",
|
||||
"upstreams": "アップストリーム",
|
||||
"url_label": "URL",
|
||||
"use_saved_key": "以前に保存したキーを使用する",
|
||||
"user_rules": "ユーザーフィルタ",
|
||||
"user_rules_add_to_allowlist": "ホワイトリストに追加する",
|
||||
"user_rules_check_button": "チェックする",
|
||||
"user_rules_check_client_placeholder": "クライアント識別子を入力してください",
|
||||
"user_rules_check_hostname_label": "ホスト名またはドメイン名",
|
||||
"user_rules_disable_filter": "フィルタを無効にする",
|
||||
"user_rules_disable_parental_control": "ペアレンタルコントロールを無効にする",
|
||||
"user_rules_dns_record_type_placeholder": "DNSレコードタイプ(DNS record type)を選択",
|
||||
"user_rules_edit_dns_rewrite": "DNS rewrite を編集する",
|
||||
"user_rules_rewrite_rule_is_applied": "書き換えルールを適用済み",
|
||||
"user_rules_title": "カスタム・フィルタリングルール",
|
||||
"username_label": "ユーザー名",
|
||||
"username_placeholder": "ユーザ名を入力してください",
|
||||
"validated_with_dnssec": "DNSSECにて検証済",
|
||||
"wednesday": "水曜日",
|
||||
"whois": "WHOIS",
|
||||
"yes": "Yes"
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
{
|
||||
"access_settings_title": "접근 설정",
|
||||
"actions_label": "가능한 동작",
|
||||
"actions_table_header": "가능한 동작",
|
||||
"add": "추가",
|
||||
"add_allowlist": "허용 목록 추가",
|
||||
"add_blocklist": "차단 목록 추가",
|
||||
"add_persistent_client": "저장된 클라이언트에 추가",
|
||||
"add_to_allowlist": "허용 목록에 추가",
|
||||
"ads_blocked": "차단된 광고",
|
||||
"ads_blocked_card": "차단된 광고",
|
||||
"adult_websites_blocked_tooltip": "차단된 성인 웹 사이트의 수",
|
||||
"all_lists_up_to_date_toast": "모든 리스트가 이미 최신입니다",
|
||||
"allowed": "허용됨",
|
||||
"allowlist_add": "허용 목록 추가",
|
||||
"allowlist_edit": "허용 목록 수정",
|
||||
"apply": "적용",
|
||||
"auto_clients_title": "런타임 클라이언트",
|
||||
"autofix_warning_result": "결과적으로 시스템의 모든 DNS 요청은 기본적으로 AdGuard Home에 의해 처리됩니다.",
|
||||
"autofix_warning_text": "'수정'을 클릭하면 AdGuard Home이 AdGuard Home DNS 서버를 사용하도록 시스템을 설정합니다.",
|
||||
"average_time_processing": "평균처리 시간",
|
||||
"average_upstream_response_time": "평균 업스트림 응답 시간",
|
||||
"back": "뒤로가기",
|
||||
"block": "차단",
|
||||
"block_for_this_client_only": "이 클라이언트에 대해서만 차단",
|
||||
"blocked": "차단됨",
|
||||
"blocked_by_cname_or_ip": "CNAME 또는 IP에 의해 차단됨",
|
||||
"blocked_service": "차단된 서비스",
|
||||
"blocked_services": "차단된 서비스",
|
||||
"blocked_threats": "차단된 위협",
|
||||
"blocked_threats_chart": "차단된 위협",
|
||||
"blocklist_add_manual": "수동으로 추가",
|
||||
"blocklist_edit": "차단 목록 수정",
|
||||
"blocklist_homepage": "홈페이지",
|
||||
"blocklist_url_file_path": "URL 또는 파일 경로",
|
||||
"blocklists_add": "차단 목록 추가",
|
||||
"bootstrap_dns": "부트스트랩 DNS 서버",
|
||||
"cache_cleared": "DNS 캐시를 성공적으로 지웠습니다",
|
||||
"cache_config_clear": "캐시 지우기",
|
||||
"cache_config_optimistic": "옵티미스틱 캐시",
|
||||
"cache_config_ttl_validation": "최소 캐시 TTL 값은 최대 값보다 이하여야 합니다",
|
||||
"cache_enabled": "캐시 활성화",
|
||||
"cancel": "취소",
|
||||
"cancel_btn": "취소",
|
||||
"category_tooltip": "카테고리",
|
||||
"changes_saved_success": "변경사항이 저장되었습니다.",
|
||||
"check_dhcp_servers": "DHCP 서버 체크",
|
||||
"check_not_found": "필터 목록에서 찾을 수 없음",
|
||||
"check_updates_btn": "업데이트 확인",
|
||||
"clear_btn": "비우기",
|
||||
"client_add": "클라이언트 추가",
|
||||
"client_details": "클라이언트 정보",
|
||||
"client_id": "ClientID",
|
||||
"client_id_placeholder": "ClientID 입력",
|
||||
"client_identifier": "식별자",
|
||||
"client_settings": "클라이언트 설정",
|
||||
"client_table_header": "클라이언트",
|
||||
"clients_add_identifier": "식별자 추가",
|
||||
"clients_dns_cache_size": "DNS 캐시 크기(바이트)",
|
||||
"clients_not_found": "클라이언트가 없습니다",
|
||||
"clients_protection": "보호",
|
||||
"clients_tags": "태그",
|
||||
"clients_title": "영구 클라이언트",
|
||||
"clients_use_global_settings": "글로벌 설정 사용",
|
||||
"close": "닫기",
|
||||
"confirm": "확인",
|
||||
"copied": "클립보드에 복사되었습니다.",
|
||||
"copy": "복사",
|
||||
"custom_filter_rules": "커스텀 필터링 규칙",
|
||||
"custom_filtering_rules": "커스텀 필터링 규칙",
|
||||
"dark_theme": "다크 테마",
|
||||
"dashboard": "대시보드",
|
||||
"delete_btn": "삭제",
|
||||
"delete_table_action": "삭제",
|
||||
"dhcp_add_static_lease": "고정 임대 추가",
|
||||
"dhcp_config_saved": "DHCP 구성이 성공적으로 저장되었습니다",
|
||||
"dhcp_edit_static_lease": "고정 임대 수정",
|
||||
"dhcp_form_gateway_input": "게이트웨이 IP",
|
||||
"dhcp_form_lease_title": "DHCP 임대 시간 (초 단위로 표시됩니다)",
|
||||
"dhcp_form_subnet_input": "서브넷 마스크",
|
||||
"dhcp_hardware_address": "하드웨어 주소",
|
||||
"dhcp_interface_select": "DHCP 인터페이스 선택",
|
||||
"dhcp_ip_addresses": "IP 주소",
|
||||
"dhcp_leases_not_found": "DHCP 임대를 찾을 수 없음",
|
||||
"dhcp_new_static_lease": "새 고정 임대",
|
||||
"dhcp_reset_leases": "모든 임대 초기화",
|
||||
"dhcp_reset_leases_success": "DHCP 임대 성공적으로 초기화됨",
|
||||
"dhcp_settings": "DHCP 설정",
|
||||
"dhcp_static_leases_not_found": "DHCP 고정 임대를 찾을 수 없음",
|
||||
"dhcp_table_hostname": "호스트 이름",
|
||||
"dhcp_table_ip_address": "IP 주소",
|
||||
"dhcp_warning": "DHCP 서버를 사용하려면 네트워크에 다른 활성화된 DHCP 서버가 없는지 확인해 주세요. 다른 활성 DHCP 서버가 있다면, 연결된 장치의 인터넷이 끊길 수 있습니다.",
|
||||
"disable": "비활성화",
|
||||
"disable_notify_until_tomorrow": "내일까지 보호 기능 비활성화",
|
||||
"disable_protection_btn": "보호 끄기",
|
||||
"disabled_dhcp": "DHCP 서버 비활성화됨",
|
||||
"disabled_protection": "보호 비활성화됨",
|
||||
"disallow_this_client": "클라이언트 거부",
|
||||
"dns_cache_config": "DNS 캐시 구성",
|
||||
"dns_config": "DNS 서버 설정",
|
||||
"dns_over_https": "DNS-over-HTTPS",
|
||||
"dns_over_quic": "DNS-over-QUIC",
|
||||
"dns_over_tls": "DNS-over-TLS",
|
||||
"dns_privacy": "DNS 프라이버시",
|
||||
"dns_query": "DNS 쿼리",
|
||||
"dns_rewrites": "DNS 변경",
|
||||
"dns_settings": "DNS 설정",
|
||||
"dns_test_ok_toast": "지정된 DNS 서버가 올바르게 작동하고 있습니다.",
|
||||
"domain": "도메인",
|
||||
"domain_or_client": "도메인 또는 클라이언트",
|
||||
"down": "다운로드",
|
||||
"download_mobileconfig": "설정 파일 내려받기",
|
||||
"edit_table_action": "편집",
|
||||
"enable": "활성화",
|
||||
"enabled_dhcp": "DHCP 서버 활성화됨",
|
||||
"enabled_protection": "보호 활성화됨",
|
||||
"enabled_table_header": "사용 중",
|
||||
"encryption_certificate_path": "인증서 경로",
|
||||
"encryption_certificates": "인증서",
|
||||
"encryption_certificates_input": "PEM으로 인코딩된 인증서 여기에 복사/붙여넣기하세요.",
|
||||
"encryption_certificates_source_content": "인증서 내용 붙여넣기",
|
||||
"encryption_certificates_source_path": "인증서 파일 경로 설정",
|
||||
"encryption_chain_invalid": "인증서 체인이 유효하지 않습니다",
|
||||
"encryption_chain_valid": "인증서 체인이 유효합니다",
|
||||
"encryption_config_saved": "암호화 구성이 저장되었습니다",
|
||||
"encryption_doq": "DNS-over-QUIC 포트",
|
||||
"encryption_dot": "DNS-over-TLS 포트",
|
||||
"encryption_https": "HTTP 포트",
|
||||
"encryption_key": "개인 키",
|
||||
"encryption_key_input": "PEM으로 인코딩된 개인 키를 여기에 복사/붙여넣기하세요.",
|
||||
"encryption_key_source_content": "비밀키 내용 붙여넣기",
|
||||
"encryption_plain_dns": "평문 DNS",
|
||||
"encryption_plain_dns_error": "평문 DNS를 비활성화하려면, 암호화된 DNS 프로토콜을 하나 이상 활성화하세요",
|
||||
"encryption_private_key_path": "비밀키 경로",
|
||||
"encryption_server": "서버 이름",
|
||||
"encryption_server_enter": "도메인 이름을 입력하세요.",
|
||||
"encryption_title": "암호화",
|
||||
"enter_cache_size": "캐시 크기를 입력하세요",
|
||||
"enter_ip_address_placeholder": "IP 주소 입력",
|
||||
"error": "오류",
|
||||
"fallback_dns": "폴백 DNS 서버",
|
||||
"filter_category_general": "General",
|
||||
"filter_category_general_desc": "대부분의 기기에서 추적 및 광고를 차단하는 목록",
|
||||
"filter_category_other": "기타",
|
||||
"filter_category_other_desc": "기타 차단 목록",
|
||||
"filter_category_regional": "지역 목록",
|
||||
"filter_category_regional_desc": "지역 광고 및 추적 서버에 중점을 둔 목록",
|
||||
"filter_category_security": "멀웨어, 피싱 차단",
|
||||
"filter_category_security_desc": "악성 및 피싱 도메인을 차단하는 목록",
|
||||
"filter_removed_successfully": "목록이 성공적으로 제거되었습니다",
|
||||
"filters": "필터 구독하기",
|
||||
"forgot_password": "비밀번호를 잊어버렸나요?",
|
||||
"form_enter_blocked_response_ttl": "차단된 응답 TTL(초)을 입력하세요.",
|
||||
"form_error_answer_format": "답변 형식이 잘못되었습니다",
|
||||
"form_error_client_id_format": "ClientID는 숫자, 소문자 및 붙임표(-)만 포함해야 합니다",
|
||||
"form_error_domain_format": "도메인 형식이 잘못되었습니다",
|
||||
"form_error_equal": "동일하지 않아야 합니다",
|
||||
"form_error_gateway_ip": "임대는 게이트웨이의 IP 주소를 가질 수 없습니다",
|
||||
"form_error_ip4_format": "잘못된 IPv4 형식",
|
||||
"form_error_ip6_format": "잘못된 IPv6 주소",
|
||||
"form_error_ip_format": "유효하지 않은 IP주소",
|
||||
"form_error_mac_format": "잘못된 MAC 주소",
|
||||
"form_error_password": "비밀번호 불일치",
|
||||
"form_error_port": "유효한 포트 번호를 입력하세요",
|
||||
"form_error_port_range": "80-65535 범위의 포트 번호를 입력하세요",
|
||||
"form_error_port_unsafe": "안전하지 않은 포트입니다",
|
||||
"form_error_required": "이 필드를 작성해 주세요.",
|
||||
"form_error_server_name": "유효하지 않은 서버 이름",
|
||||
"form_error_url_or_path_format": "목록의 URL 또는 절대 경로가 잘못되었습니다",
|
||||
"friday": "금요일",
|
||||
"gateway_or_subnet_invalid": "잘못된 서브넷 마스크",
|
||||
"general_settings": "일반 설정",
|
||||
"general_statistics": "일반 통계",
|
||||
"greater_range_start_error": "범위 시작보다 큰 값이어야 합니다",
|
||||
"ignore_domains_desc_query": "이러한 규칙과 일치하는 쿼리는 쿼리 로그에 기록되지 않습니다.",
|
||||
"ignore_domains_title": "무시된 도메인",
|
||||
"inactivity_schedule_add": "일정 추가",
|
||||
"inactivity_schedule_edit": "일정 수정",
|
||||
"install_auth_confirm": "비밀번호 확인",
|
||||
"install_auth_password": "비밀번호",
|
||||
"install_auth_password_enter": "비밀번호 입력:",
|
||||
"install_auth_username": "아이디",
|
||||
"install_auth_username_enter": "사용자 이름 입력",
|
||||
"install_saved": "성공적으로 저장되었습니다",
|
||||
"install_settings_all_interfaces": "모든 인터페이스",
|
||||
"install_settings_port": "포트",
|
||||
"install_static_error": "AdGuard Home는 이 네트워크 인터페이스에서 자동 설정할 수 없습니다. 여기에서 어떻게 이걸 수동으로 할 수 있는지 확인해주세요.",
|
||||
"install_static_ok": "좋은 소식입니다! 고정 IP 주소가 이미 설정되어있네요",
|
||||
"install_step": "단계",
|
||||
"install_submit_title": "축하합니다!",
|
||||
"ip_address": "IP 주소",
|
||||
"known_tracker": "알려진 추적기",
|
||||
"light_theme": "라이트 테마",
|
||||
"login": "로그인",
|
||||
"logout": "로그아웃",
|
||||
"make_static": "정적으로 만들기",
|
||||
"milliseconds_abbreviation": "ms",
|
||||
"monday": "월요일",
|
||||
"name": "이름",
|
||||
"name_label": "이름",
|
||||
"name_table_header": "이름",
|
||||
"name_tooltip": "이름",
|
||||
"next": "다음",
|
||||
"no": "아니오",
|
||||
"nothing_found": "아무것도 찾을 수 없습니다",
|
||||
"notify_undo": "되돌리기",
|
||||
"notify_updated": "업데이트 됨",
|
||||
"parental_control": "자녀 보호",
|
||||
"parental_group_cdn": "CDN",
|
||||
"parental_group_development": "소프트웨어 개발",
|
||||
"parental_group_gaming": "게임",
|
||||
"parental_group_hosting": "웹 호스팅",
|
||||
"parental_group_privacy": "개인정보 보호 도구",
|
||||
"parental_group_shopping": "쇼핑",
|
||||
"parental_group_social": "소셜 네트워크",
|
||||
"parental_group_social_network": "소셜 네트워크",
|
||||
"parental_group_software": "소프트웨어 개발",
|
||||
"parental_group_streaming": "스트리밍",
|
||||
"password_label": "비밀번호",
|
||||
"password_placeholder": "비밀번호 입력:",
|
||||
"plain_dns": "평문 DNS",
|
||||
"privacy_policy": "개인정보취급방침",
|
||||
"protection": "보호",
|
||||
"protocol": "프로토콜 선택",
|
||||
"query_log_allowed": "허용됨",
|
||||
"query_log_blocked": "차단됨",
|
||||
"query_log_blocked_services": "차단된 서비스",
|
||||
"query_log_blocked_threats": "차단된 위협",
|
||||
"query_log_custom_filtering_rules": "커스텀 필터링 규칙",
|
||||
"query_log_processed": "처리됨",
|
||||
"query_log_retention": "쿼리 로그 로테이션",
|
||||
"query_log_rewritten": "재작성됨",
|
||||
"query_log_safe_search": "세이프서치 사용",
|
||||
"query_log_strict_search": "검색을 제한하려면 쌍따옴표를 사용해주세요",
|
||||
"rate_limit_subnet_len_ipv4_error": "IPv4 서브넷 접두사 길이는 0에서 32 사이여야 합니다.",
|
||||
"rate_limit_subnet_len_ipv6_error": "IPv6 서브넷 접두사 길이는 0에서 128 사이여야 합니다.",
|
||||
"refresh_btn": "새로고침",
|
||||
"refresh_statics": "통계 새로 고침",
|
||||
"release_notes": "릴리스 노트",
|
||||
"remove": "제거",
|
||||
"report_an_issue": "문제 신고",
|
||||
"request_table_header": "요청",
|
||||
"requests_table_header": "요청",
|
||||
"reset": "초기화",
|
||||
"reset_settings": "설정 초기화",
|
||||
"reset_settings_confirm": "예, 제거",
|
||||
"response_details": "응답 정보",
|
||||
"response_time": "응답 시간",
|
||||
"result": "결과",
|
||||
"rewrite_add": "DNS 변환 정보를 추가합니다",
|
||||
"rewrite_edit": "DNS 다시 쓰기 편집",
|
||||
"rewrite_hosts_applied": "호스트 파일 규칙에 따라 재작성",
|
||||
"rewrite_settings_updated": "DNS 재작성 설정이 성공적으로 업데이트되었습니다.",
|
||||
"rewrite_updated": "DNS 다시 쓰기 업데이트 완료",
|
||||
"rewritten": "재작성됨",
|
||||
"rules_label": "필터 규칙",
|
||||
"safe_browsing": "세이프 브라우징",
|
||||
"safe_search": "세이프서치 사용",
|
||||
"saturday": "토요일",
|
||||
"save": "저장",
|
||||
"save_btn": "저장",
|
||||
"search_placeholder": "검색",
|
||||
"server_config_blocking_mode": "차단 모드",
|
||||
"server_config_blocking_mode_ipv4": "IPv4 차단",
|
||||
"server_config_blocking_mode_ipv6": "IPv6 차단",
|
||||
"server_config_custom_ip": "사용자 지정 IP주소",
|
||||
"server_config_default": "기본",
|
||||
"server_config_disable_ipv6": "IPv6 주소 확인 비활성화",
|
||||
"server_config_dnssec_enable": "DNSSEC 활성화",
|
||||
"server_config_edns_enable": "EDNS 클라이언트 서브넷 활성화",
|
||||
"server_config_nxdomain": "NXDOMAIN",
|
||||
"server_config_rate_limit_placeholder": "한도 제한 입력하기",
|
||||
"server_config_rate_limit_whitelist_faq": "속도 제한에서 제외되는 IP 주소",
|
||||
"server_config_refused": "REFUSED",
|
||||
"server_config_subnet_len_ipv4": "IPv4 주소의 서브넷 접두사 길이",
|
||||
"server_config_subnet_len_ipv4_faq": "속도 제한에 사용되는 IPv4 주소의 서브넷 접두사 길이입니다. 기본값은 24입니다.",
|
||||
"server_config_subnet_len_ipv6": "IPv6 주소의 서브넷 접두사 길이",
|
||||
"server_config_subnet_len_ipv6_faq": "속도 제한에 사용되는 IPv6 주소의 서브넷 접두사 길이입니다. 기본값은 56입니다.",
|
||||
"set_static_ip": "고정 IP 주소 설정",
|
||||
"settings": "설정",
|
||||
"settings_browsing_security": "브라우징 보안",
|
||||
"settings_confirm_clear_statistics": "통계를 삭제하시겠습니까?",
|
||||
"settings_custom": "사용자 정의",
|
||||
"settings_general_short": "General",
|
||||
"settings_global": "글로벌",
|
||||
"settings_notify_changes_saved": "변경사항이 저장되었습니다.",
|
||||
"settings_notify_statistics_cleared": "통계 삭제됨",
|
||||
"settings_parental_control": "자녀 보호",
|
||||
"settings_rule_syntax": "규칙 구문",
|
||||
"settings_safe_search": "세이프서치 사용",
|
||||
"settings_statistics": "통계",
|
||||
"settings_statistics_clear": "통계 비우기",
|
||||
"settings_statistics_retention": "통계 저장 기간",
|
||||
"settings_yes_clear": "네, 지우기",
|
||||
"setup_devices_router_title": "라우터",
|
||||
"setup_dns_privacy_ioc_mac": "iOS 및 macOS 설정",
|
||||
"setup_dns_privacy_other_title": "기타 구현",
|
||||
"show_blocked_responses": "차단됨",
|
||||
"show_processed_responses": "처리됨",
|
||||
"source_label": "소스",
|
||||
"source_tooltip": "소스",
|
||||
"stats_adult": "차단된 성인 웹사이트",
|
||||
"stats_query_domain": "쿼리 도메인",
|
||||
"status_table_header": "상태",
|
||||
"subnet_error": "주소는 하나의 서브넷에 있어야 합니다",
|
||||
"sunday": "일요일",
|
||||
"system_host_files": "시스템 호스트 파일",
|
||||
"table_client": "클라이언트",
|
||||
"tags_title": "태그",
|
||||
"test_upstreams": "업스트림 테스트",
|
||||
"threats_blocked": "차단된 위협",
|
||||
"thursday": "목요일",
|
||||
"time_table_header": "시간",
|
||||
"top_blocked_domains": "차단된 도메인",
|
||||
"top_clients": "클라이언트",
|
||||
"top_upstreams": "상위 업스트림",
|
||||
"total": "합계",
|
||||
"try_again": "다시 시도해보세요",
|
||||
"tuesday": "화요일",
|
||||
"unavailable_dhcp": "DHCP가 사용 불가능합니다.",
|
||||
"unavailable_dhcp_desc": "AdGuard Home은 이 OS에서 DHCP 서버를 구동할 수 없습니다.",
|
||||
"unblock": "차단 해제",
|
||||
"update_filters_disable": "비활성화",
|
||||
"update_filters_interval_custom": "사용자 정의",
|
||||
"update_filters_interval_daily": "매일",
|
||||
"update_filters_interval_hourly": "한 시간 마다",
|
||||
"update_filters_title": "필터를 자동으로 업데이트합니다.",
|
||||
"updated_custom_filtering_toast": "사용자 정의 규칙이 성공적으로 저장되었습니다",
|
||||
"updated_upstream_dns_toast": "업스트림 서버가 성공적으로 저장되었습니다",
|
||||
"updates_checked": "AdGuard Home의 새 버전을 사용할 수 있습니다",
|
||||
"updates_version_equal": "AdGuard Home 최신 상태입니다.",
|
||||
"upstream": "업스트림",
|
||||
"upstream_dns": "업스트림 DNS 서버",
|
||||
"upstream_dns_addresses": "서버 주소, 한 줄에 하나씩 입력하세요",
|
||||
"upstream_dns_fastest_addr": "가장 빠른 IP 주소",
|
||||
"upstream_dns_load_balancing": "로드 밸런싱",
|
||||
"upstream_dns_parallel_requests": "병렬 처리 요청",
|
||||
"upstream_dns_servers_title": "업스트림 DNS 서버",
|
||||
"upstream_examples_title": "예시",
|
||||
"upstream_private_ptr_resolvers_title": "프라이빗 역방향 DNS 리졸버 사용",
|
||||
"upstreams": "업스트림",
|
||||
"url_label": "URL",
|
||||
"use_saved_key": "이전에 저장했던 키 사용하기",
|
||||
"user_rules": "사용자 규칙",
|
||||
"user_rules_add_to_allowlist": "허용 목록에 추가",
|
||||
"user_rules_check_button": "확인",
|
||||
"user_rules_check_client_placeholder": "클라이언트 식별자 입력",
|
||||
"user_rules_check_hostname_label": "호스트 이름 또는 도메인 이름",
|
||||
"user_rules_disable_filter": "필터 비활성화",
|
||||
"user_rules_disable_parental_control": "자녀 보호 비활성화",
|
||||
"user_rules_dns_record_type_placeholder": "DNS 레코드 유형 선택",
|
||||
"user_rules_domain_blocked": "차단된 도메인",
|
||||
"user_rules_edit_dns_rewrite": "DNS 다시 쓰기 편집",
|
||||
"user_rules_rewrite_rule_is_applied": "리디렉션 규칙이 적용됩니다",
|
||||
"user_rules_title": "커스텀 필터링 규칙",
|
||||
"username_label": "아이디",
|
||||
"username_placeholder": "사용자 이름 입력",
|
||||
"validated_with_dnssec": "DNSSEC로 검증됨",
|
||||
"wednesday": "수요일",
|
||||
"whois": "WHOIS",
|
||||
"yes": "네"
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
{
|
||||
"access_settings_title": "Configurações de acessos",
|
||||
"actions_label": "Ações",
|
||||
"actions_table_header": "Ações",
|
||||
"add": "Adicionar",
|
||||
"add_allowlist": "Adicionar lista de permissões",
|
||||
"add_blocklist": "Adicionar lista de bloqueio",
|
||||
"add_persistent_client": "Adicionar como cliente persistente",
|
||||
"add_to_allowlist": "Adicionar na lista de permissões",
|
||||
"ads_blocked": "anúncios bloqueados",
|
||||
"ads_blocked_card": "anúncios bloqueados",
|
||||
"adult_websites_blocked_tooltip": "O número de sites adultos bloqueados",
|
||||
"all_lists_up_to_date_toast": "Todas as listas já estão atualizadas",
|
||||
"allowed": "Na lista branca",
|
||||
"allowlist_add": "Adicionar lista de permissões",
|
||||
"allowlist_edit": "Editar lista de permissões",
|
||||
"apply": "Aplicar",
|
||||
"auto_clients_title": "Clientes ativos",
|
||||
"autofix_warning_result": "Como resultado, todos as solicitações DNS do seu sistema serão processadas pelo AdGuard Home por padrão.",
|
||||
"autofix_warning_text": "Se clicar em \"Corrigir\", o AdGuardHome irá configurar o seu sistema para utilizar o servidor DNS do AdGuardHome.",
|
||||
"average_time_processing": "Tempo médio de processamento",
|
||||
"average_upstream_response_time": "Tempo médio de resposta upstream",
|
||||
"back": "Voltar",
|
||||
"block": "Bloquear",
|
||||
"block_for_this_client_only": "Bloquear apenas para este cliente",
|
||||
"blocked": "Bloqueado",
|
||||
"blocked_by_cname_or_ip": "Bloqueado por CNAME ou IP",
|
||||
"blocked_service": "Serviço bloqueado",
|
||||
"blocked_services": "Serviços bloqueados",
|
||||
"blocked_threats": "Ameaças bloqueadas",
|
||||
"blocked_threats_chart": "Ameaças bloqueadas",
|
||||
"blocklist_add_manual": "Adicionar manualmente",
|
||||
"blocklist_edit": "Editar lista de bloqueio",
|
||||
"blocklist_homepage": "Página inicial",
|
||||
"blocklist_url_file_path": "URL ou caminho do arquivo",
|
||||
"blocklists_add": "Adicionar lista de bloqueio",
|
||||
"bootstrap_dns": "Servidores DNS de inicialização",
|
||||
"cache_cleared": "Cache DNS limpo com sucesso",
|
||||
"cache_config_clear": "Limpar cache",
|
||||
"cache_config_optimistic": "Cache otimista",
|
||||
"cache_config_ttl_validation": "O substituto mínimo de cache TTL deve ser menor ou igual ao máximo",
|
||||
"cache_enabled": "Ativar cache",
|
||||
"cancel": "Cancelar",
|
||||
"cancel_btn": "Cancelar",
|
||||
"category_tooltip": "Categoria",
|
||||
"changes_saved_success": "Alterações salvas",
|
||||
"check_dhcp_servers": "Verificar por servidores DHCP",
|
||||
"check_not_found": "Não encontrado em suas listas de filtros",
|
||||
"check_updates_btn": "Verificar atualizações",
|
||||
"clear_btn": "Limpar",
|
||||
"client_add": "Adicionar cliente",
|
||||
"client_details": "Detalhes do cliente",
|
||||
"client_id": "ID do cliente",
|
||||
"client_id_placeholder": "Digite o ID do cliente",
|
||||
"client_identifier": "Identificador",
|
||||
"client_settings": "Configurações do cliente",
|
||||
"client_table_header": "Cliente",
|
||||
"clients_add_identifier": "Adicionar identificador",
|
||||
"clients_dns_cache_size": "Tamanho do cache do DNS, em bytes",
|
||||
"clients_not_found": "Nenhuma cliente encontrado",
|
||||
"clients_protection": "Proteção",
|
||||
"clients_tags": "Marcadores",
|
||||
"clients_title": "Clientes persistentes",
|
||||
"clients_use_global_settings": "Usar configurações global",
|
||||
"close": "Fechar",
|
||||
"confirm": "Confirmar",
|
||||
"copied": "Copiado para a área de transferência",
|
||||
"copy": "Copiar",
|
||||
"custom_filter_rules": "Regras de filtragem personalizadas",
|
||||
"custom_filtering_rules": "Regras de filtragem personalizadas",
|
||||
"dark_theme": "Tema escuro",
|
||||
"dashboard": "Painel",
|
||||
"delete_btn": "Excluir",
|
||||
"delete_table_action": "Excluir",
|
||||
"dhcp_add_static_lease": "Adicionar nova concessão estática",
|
||||
"dhcp_config_saved": "Configurações DHCP salvas com sucesso",
|
||||
"dhcp_edit_static_lease": "Editar concessão estática",
|
||||
"dhcp_form_gateway_input": "IP do gateway",
|
||||
"dhcp_form_lease_title": "Tempo de concessão do DHCP (em segundos)",
|
||||
"dhcp_form_subnet_input": "Máscara de sub-rede",
|
||||
"dhcp_hardware_address": "Endereço de hardware",
|
||||
"dhcp_interface_select": "Selecione a interface DHCP",
|
||||
"dhcp_ip_addresses": "Endereço de IP",
|
||||
"dhcp_leases_not_found": "Nenhuma concessão DHCP encontrada",
|
||||
"dhcp_new_static_lease": "Nova concessão estática",
|
||||
"dhcp_reset_leases": "Redefinir todas as concessões",
|
||||
"dhcp_reset_leases_success": "Concessões de DHCP redefinidas com sucesso",
|
||||
"dhcp_settings": "Configurações de DHCP",
|
||||
"dhcp_static_leases_not_found": "Nenhuma concessão DHCP estática foi encontrada",
|
||||
"dhcp_table_hostname": "Nome do servidor",
|
||||
"dhcp_table_ip_address": "Endereço de IP",
|
||||
"dhcp_warning": "Se você quiser ativar o servidor DHCP de qualquer maneira, certifique-se de que não haja outro servidor DHCP ativo em sua rede, pois isso pode quebrar a conectividade com a Internet para dispositivos na rede!",
|
||||
"disable": "Desativar",
|
||||
"disable_notify_until_tomorrow": "Desativar a proteção até amanhã",
|
||||
"disable_protection_btn": "Desativar proteção",
|
||||
"disabled_dhcp": "Servidor DHCP desativado",
|
||||
"disabled_protection": "Proteção desativada",
|
||||
"disallow_this_client": "Não permitir este cliente",
|
||||
"dns_cache_config": "Configuração de cache DNS",
|
||||
"dns_config": "Configuração do servidor DNS",
|
||||
"dns_over_https": "DNS-sobre-HTTPS",
|
||||
"dns_over_quic": "DNS-sobre-QUIC",
|
||||
"dns_over_tls": "DNS-sobre-TLS",
|
||||
"dns_privacy": "Privacidade de DNS",
|
||||
"dns_query": "Consultas de DNS",
|
||||
"dns_rewrites": "Reescritas de DNS",
|
||||
"dns_settings": "Configurações de DNS",
|
||||
"dns_test_ok_toast": "Os servidores DNS especificados estão funcionando corretamente",
|
||||
"domain": "Domínio",
|
||||
"domain_or_client": "Domínio ou cliente",
|
||||
"down": "Caiu",
|
||||
"download_mobileconfig": "Baixar arquivo de configuração",
|
||||
"edit_table_action": "Editar",
|
||||
"enable": "Ativar",
|
||||
"enabled_dhcp": "Servidor DHCP ativado",
|
||||
"enabled_protection": "Proteção ativada",
|
||||
"enabled_table_header": "Ativado",
|
||||
"encryption_certificate_path": "Caminho do certificado",
|
||||
"encryption_certificates": "Certificados",
|
||||
"encryption_certificates_input": "Copie/cole aqui seu certificado codificado em PEM.",
|
||||
"encryption_certificates_source_content": "Colar o conteúdo dos certificados",
|
||||
"encryption_certificates_source_path": "Definir o caminho do arquivo de certificados",
|
||||
"encryption_chain_invalid": "A cadeia de certificado é inválida",
|
||||
"encryption_chain_valid": "Cadeia de chave válida",
|
||||
"encryption_config_saved": "Configuração de criptografia salva",
|
||||
"encryption_doq": "Porta DNS-sobre-QUIC",
|
||||
"encryption_dot": "Porta DNS-sobre-TLS",
|
||||
"encryption_https": "Porta HTTPS",
|
||||
"encryption_key": "Chave privada",
|
||||
"encryption_key_input": "Copie/cole aqui a chave privada codificada em PEM para seu certificado.",
|
||||
"encryption_key_source_content": "Colar o conteúdo da chave privada",
|
||||
"encryption_plain_dns": "DNS simples",
|
||||
"encryption_plain_dns_error": "Para desativar o DNS simples, ative pelo menos um protocolo DNS criptografado",
|
||||
"encryption_private_key_path": "Caminho da chave privada",
|
||||
"encryption_server": "Nome do servidor",
|
||||
"encryption_server_enter": "Digite seu nome de domínio",
|
||||
"encryption_title": "Criptografia",
|
||||
"enter_cache_size": "Digite o tamanho do cache (bytes)",
|
||||
"enter_ip_address_placeholder": "Digite o endereço de IP",
|
||||
"error": "Erro",
|
||||
"fallback_dns": "Servidores DNS Fallback",
|
||||
"filter_category_general": "Geral",
|
||||
"filter_category_general_desc": "Listas que bloqueiam o rastreamento e a publicidade na maioria dos dispositivos",
|
||||
"filter_category_other": "Outros",
|
||||
"filter_category_other_desc": "Outras listas de bloqueio",
|
||||
"filter_category_regional": "Regional",
|
||||
"filter_category_regional_desc": "Listas focadas em anúncios regionais e servidores de rastreamento",
|
||||
"filter_category_security": "Segurança",
|
||||
"filter_category_security_desc": "Listas projetadas especificamente em bloquear domínios maliciosos, de phishing e fraude",
|
||||
"filter_removed_successfully": "A lista foi removida com sucesso",
|
||||
"filters": "Filtros",
|
||||
"forgot_password": "Esqueceu sua senha?",
|
||||
"form_enter_blocked_response_ttl": "Insira o TTL da resposta bloqueada (segundos)",
|
||||
"form_error_answer_format": "Formato de resposta inválido",
|
||||
"form_error_client_id_format": "O ID do cliente deve conter apenas números, letras minúsculas e hifens",
|
||||
"form_error_domain_format": "Formato de domínio inválido",
|
||||
"form_error_equal": "Não deve ser igual",
|
||||
"form_error_gateway_ip": "A concessão não pode ter o endereço IP do gateway",
|
||||
"form_error_ip4_format": "Endereço de IPv4 inválido",
|
||||
"form_error_ip6_format": "Endereço de IPv6 inválido",
|
||||
"form_error_ip_format": "Endereço de IP inválido",
|
||||
"form_error_mac_format": "Endereço de MAC inválido",
|
||||
"form_error_password": "Senhas não coincidem",
|
||||
"form_error_port": "Digite um numero de porta válida",
|
||||
"form_error_port_range": "Digite um número de porta entre 80 e 65535",
|
||||
"form_error_port_unsafe": "Porta não é segura",
|
||||
"form_error_required": "Por favor, preencha este campo",
|
||||
"form_error_server_name": "Nome de servidor inválido",
|
||||
"form_error_url_or_path_format": "URL ou local da lista inválida",
|
||||
"friday": "Sexta-feira",
|
||||
"gateway_or_subnet_invalid": "Máscara de sub-rede inválida",
|
||||
"general_settings": "Configurações gerais",
|
||||
"general_statistics": "Estatísticas gerais",
|
||||
"greater_range_start_error": "Deve ser maior que o início do intervalo",
|
||||
"ignore_domains_desc_query": "As consultas que correspondem a essas regras não são gravadas no registro de consultas",
|
||||
"ignore_domains_title": "Domínios ignorados",
|
||||
"inactivity_schedule_add": "Adicionar agendamento",
|
||||
"inactivity_schedule_edit": "Editar agendamento",
|
||||
"install_auth_confirm": "Confirmar senha",
|
||||
"install_auth_password": "Senha",
|
||||
"install_auth_password_enter": "Digite a senha",
|
||||
"install_auth_username": "Nome de usuário",
|
||||
"install_auth_username_enter": "Digite o nome de usuário",
|
||||
"install_saved": "Salvo com sucesso",
|
||||
"install_settings_all_interfaces": "Todas interfaces",
|
||||
"install_settings_port": "Porta",
|
||||
"install_static_error": "O AdGuard Home não pode configurar automaticamente para esta interface de rede. Por favor, procure uma instrução sobre como fazer isso manualmente.",
|
||||
"install_static_ok": "Boas notícias! O endereço de IP estático já está configurado",
|
||||
"install_step": "Passo",
|
||||
"install_submit_title": "Parabéns!",
|
||||
"ip_address": "Endereço de IP",
|
||||
"known_tracker": "Rastreador conhecido",
|
||||
"light_theme": "Tema claro",
|
||||
"login": "Entrar",
|
||||
"logout": "Sair",
|
||||
"make_static": "Tornar estático",
|
||||
"milliseconds_abbreviation": "ms",
|
||||
"monday": "Segunda-feira",
|
||||
"name": "Nome",
|
||||
"name_label": "Nome",
|
||||
"name_table_header": "Nome",
|
||||
"name_tooltip": "Nome",
|
||||
"next": "Próximo",
|
||||
"no": "Não",
|
||||
"nothing_found": "Nada encontrado",
|
||||
"notify_undo": "Desfazer",
|
||||
"notify_updated": "Atualizado",
|
||||
"parental_control": "Controle parental",
|
||||
"parental_group_cdn": "CDN",
|
||||
"parental_group_development": "Desenvolvimento de software",
|
||||
"parental_group_gaming": "Jogos digitais",
|
||||
"parental_group_hosting": "Hospedagem web",
|
||||
"parental_group_privacy": "Ferramentas de privacidade",
|
||||
"parental_group_shopping": "Compras",
|
||||
"parental_group_social": "Redes sociais",
|
||||
"parental_group_social_network": "Redes sociais",
|
||||
"parental_group_software": "Desenvolvimento de software",
|
||||
"parental_group_streaming": "Streaming",
|
||||
"password_label": "Senha",
|
||||
"password_placeholder": "Digite a senha",
|
||||
"plain_dns": "DNS simples",
|
||||
"privacy_policy": "Política de privacidade",
|
||||
"protection": "Proteção",
|
||||
"protocol": "Protocolo",
|
||||
"query_log_allowed": "Na lista branca",
|
||||
"query_log_blocked": "Bloqueado",
|
||||
"query_log_blocked_services": "Serviços bloqueados",
|
||||
"query_log_blocked_threats": "Ameaças bloqueadas",
|
||||
"query_log_custom_filtering_rules": "Regras de filtragem personalizadas",
|
||||
"query_log_processed": "Processado",
|
||||
"query_log_retention": "Rotação de registros de consulta",
|
||||
"query_log_rewritten": "Reescrito",
|
||||
"query_log_safe_search": "Pesquisa segura",
|
||||
"query_log_strict_search": "Use aspas duplas para uma pesquisa mais criteriosa",
|
||||
"rate_limit_subnet_len_ipv4_error": "O comprimento do prefixo da sub-rede IPv4 deve estar entre 0 e 32",
|
||||
"rate_limit_subnet_len_ipv6_error": "O comprimento do prefixo da sub-rede IPv6 deve estar entre 0 e 128",
|
||||
"refresh_btn": "Atualizar",
|
||||
"refresh_statics": "Atualizar estatísticas",
|
||||
"release_notes": "Notas de lançamento",
|
||||
"remove": "Remover",
|
||||
"report_an_issue": "Reportar um problema",
|
||||
"request_table_header": "Solicitação",
|
||||
"requests_table_header": "Solicitações",
|
||||
"reset": "Redefinir",
|
||||
"reset_settings": "Redefinir configurações",
|
||||
"reset_settings_confirm": "Sim, redefinir",
|
||||
"response_details": "Detalhes da resposta",
|
||||
"response_time": "Tempo de resposta",
|
||||
"result": "Resultado",
|
||||
"rewrite_add": "Adicionar reescrita de DNS",
|
||||
"rewrite_edit": "Editar reconfiguração de DNS",
|
||||
"rewrite_hosts_applied": "Reescrito pela regra do arquivo de hosts",
|
||||
"rewrite_settings_updated": "Configurações de reescrita de DNS atualizadas com sucesso",
|
||||
"rewrite_updated": "Reconfiguração de DNS atualizada com êxito",
|
||||
"rewritten": "Reescrito",
|
||||
"rules_label": "Regras",
|
||||
"safe_browsing": "Navegação segura",
|
||||
"safe_search": "Pesquisa segura",
|
||||
"saturday": "Sábado",
|
||||
"save": "Salvar",
|
||||
"save_btn": "Salvar",
|
||||
"search_placeholder": "Procurar",
|
||||
"server_config_blocking_mode": "Modo de bloqueio",
|
||||
"server_config_blocking_mode_ipv4": "Bloqueando IPv4",
|
||||
"server_config_blocking_mode_ipv6": "Bloqueando IPv6",
|
||||
"server_config_custom_ip": "Endereço de IP personalizado",
|
||||
"server_config_default": "Padrão",
|
||||
"server_config_disable_ipv6": "Desativar resolução de endereços IPv6",
|
||||
"server_config_dnssec_enable": "Ativar DNSSEC",
|
||||
"server_config_edns_enable": "Ativar a sub-rede do cliente EDNS",
|
||||
"server_config_nxdomain": "NXDOMAIN",
|
||||
"server_config_rate_limit_placeholder": "Insira a velocidade limite",
|
||||
"server_config_rate_limit_whitelist_faq": "Endereços IP excluídos da limitação de velocidade",
|
||||
"server_config_refused": "REFUSED",
|
||||
"server_config_subnet_len_ipv4": "Comprimento do prefixo de sub-rede para endereços IPv4",
|
||||
"server_config_subnet_len_ipv4_faq": "Comprimento do prefixo de sub-rede para endereços IPv4 usados para limitação de velocidade. O padrão é 24",
|
||||
"server_config_subnet_len_ipv6": "Comprimento do prefixo de sub-rede para endereços IPv6",
|
||||
"server_config_subnet_len_ipv6_faq": "Comprimento do prefixo de sub-rede para endereços IPv6 usados para limitação de velocidade. O padrão é 56",
|
||||
"set_static_ip": "Definir um endereço de IP estático",
|
||||
"settings": "Configurações",
|
||||
"settings_browsing_security": "Segurança da navegação",
|
||||
"settings_confirm_clear_statistics": "Limpar estatísticas?",
|
||||
"settings_custom": "Personalizado",
|
||||
"settings_general_short": "Geral",
|
||||
"settings_global": "Global",
|
||||
"settings_notify_changes_saved": "Alterações salvas",
|
||||
"settings_notify_statistics_cleared": "Estatísticas apagadas",
|
||||
"settings_parental_control": "Controle parental",
|
||||
"settings_rule_syntax": "Sintaxe de regras",
|
||||
"settings_safe_search": "Pesquisa segura",
|
||||
"settings_statistics": "Estatísticas",
|
||||
"settings_statistics_clear": "Limpar estatísticas",
|
||||
"settings_statistics_retention": "Permanência das estatísticas",
|
||||
"settings_yes_clear": "Sim, limpar",
|
||||
"setup_devices_router_title": "Roteador",
|
||||
"setup_dns_privacy_ioc_mac": "configuração para iOS e macOS",
|
||||
"setup_dns_privacy_other_title": "Outras implementações",
|
||||
"show_blocked_responses": "Bloqueado",
|
||||
"show_processed_responses": "Processado",
|
||||
"source_label": "Fonte",
|
||||
"source_tooltip": "Fonte",
|
||||
"stats_adult": "Bloqueado sites adultos",
|
||||
"stats_query_domain": "Principais domínios consultados",
|
||||
"status_table_header": "Status",
|
||||
"subnet_error": "Endereços devem estar em uma sub-rede",
|
||||
"sunday": "Domingo",
|
||||
"system_host_files": "Arquivos hosts do sistema",
|
||||
"table_client": "Cliente",
|
||||
"tags_title": "Marcadores",
|
||||
"test_upstreams": "Testar DNS primário",
|
||||
"threats_blocked": "Ameaças bloqueadas",
|
||||
"thursday": "Quinta-feira",
|
||||
"time_table_header": "Data",
|
||||
"top_blocked_domains": "Principais domínios bloqueados",
|
||||
"top_clients": "Principais clientes",
|
||||
"top_upstreams": "Melhores servidores DNS primários",
|
||||
"total": "Total",
|
||||
"try_again": "Tente novamente",
|
||||
"tuesday": "Terça-feira",
|
||||
"unavailable_dhcp": "DHCP não está disponível",
|
||||
"unavailable_dhcp_desc": "O AdGuard Home não pode executar um servidor DHCP em seu sistema operacional",
|
||||
"unblock": "Desbloquear",
|
||||
"update_filters_disable": "Desativar",
|
||||
"update_filters_interval_custom": "Personalizado",
|
||||
"update_filters_interval_daily": "Diariamente",
|
||||
"update_filters_interval_hourly": "A cada hora",
|
||||
"update_filters_title": "Atualiza os filtros automaticamente",
|
||||
"updated_custom_filtering_toast": "Regras personalizadas salvas com sucesso",
|
||||
"updated_upstream_dns_toast": "Servidores DNS primário salvos com sucesso",
|
||||
"updates_checked": "Uma nova versão do AdGuard Home está disponível\n",
|
||||
"updates_version_equal": "O AdGuard Home está atualizado.",
|
||||
"upstream": "Servidor DNS primário",
|
||||
"upstream_dns": "Servidores DNS primário",
|
||||
"upstream_dns_addresses": "Endereços de servidor, um por linha",
|
||||
"upstream_dns_fastest_addr": "Endereço de IP mais rápido",
|
||||
"upstream_dns_load_balancing": "Balanceamento de carga",
|
||||
"upstream_dns_parallel_requests": "Solicitações paralelas",
|
||||
"upstream_dns_servers_title": "Servidores DNS primário",
|
||||
"upstream_examples_title": "Exemplos",
|
||||
"upstream_private_ptr_resolvers_title": "Usar resolvedores DNS reversos privados",
|
||||
"upstreams": "DNS primário",
|
||||
"url_label": "URL",
|
||||
"use_saved_key": "Use a chave salva anteriormente",
|
||||
"user_rules": "Regras de usuário",
|
||||
"user_rules_add_to_allowlist": "Adicionar na lista de permissões",
|
||||
"user_rules_check_button": "Verificar",
|
||||
"user_rules_check_client_placeholder": "Insira o identificador do cliente",
|
||||
"user_rules_check_hostname_label": "Nome do anfitrião ou nome de domínio",
|
||||
"user_rules_disable_filter": "Desativar filtro",
|
||||
"user_rules_disable_parental_control": "Desativar controle parental",
|
||||
"user_rules_dns_record_type_placeholder": "Selecione o tipo de registro DNS",
|
||||
"user_rules_domain_blocked": "O domínio está bloquado",
|
||||
"user_rules_edit_dns_rewrite": "Editar reconfiguração de DNS",
|
||||
"user_rules_rewrite_rule_is_applied": "Regra de reescrita aplicada",
|
||||
"user_rules_title": "Regras de filtragem personalizadas",
|
||||
"username_label": "Nome de usuário",
|
||||
"username_placeholder": "Digite o nome de usuário",
|
||||
"validated_with_dnssec": "Validado com DNSSEC",
|
||||
"wednesday": "Quarta-feira",
|
||||
"whois": "WHOIS",
|
||||
"yes": "Sim"
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
{
|
||||
"access_settings_title": "Definições de acesso",
|
||||
"actions_label": "Acções",
|
||||
"actions_table_header": "Acções",
|
||||
"add": "Adicionar",
|
||||
"add_allowlist": "Adicionar lista de permissões",
|
||||
"add_blocklist": "Adicionar lista de bloqueio",
|
||||
"add_persistent_client": "Adicionar como cliente persistente",
|
||||
"add_to_allowlist": "Adicionar na lista de permissões",
|
||||
"ads_blocked": "Anúncios bloqueados",
|
||||
"ads_blocked_card": "Anúncios bloqueados",
|
||||
"adult_websites_blocked_tooltip": "O número de sítios adultos bloqueados",
|
||||
"all_lists_up_to_date_toast": "Todas as listas já estão atualizadas",
|
||||
"allowed": "Na lista branca",
|
||||
"allowlist_add": "Adicionar lista de permissões",
|
||||
"allowlist_edit": "Editar lista de permissões",
|
||||
"apply": "Aplicar",
|
||||
"auto_clients_title": "Clientes ativos",
|
||||
"autofix_warning_result": "Como resultado, todos as solicitações DNS do seu sistema serão processadas pelo AdGuard Home por predefinição.",
|
||||
"autofix_warning_text": "Se clicar em \"Corrigir\", o AdGuardHome irá configurar o seu sistema para utilizar o servidor DNS do AdGuardHome.",
|
||||
"average_time_processing": "Tempo médio de processamento",
|
||||
"average_upstream_response_time": "Tempo médio de resposta upstream",
|
||||
"back": "Retroceder",
|
||||
"block": "Bloquear",
|
||||
"block_for_this_client_only": "Bloquear apenas para este cliente",
|
||||
"blocked": "Bloqueado",
|
||||
"blocked_by_cname_or_ip": "Bloqueado por CNAME ou IP",
|
||||
"blocked_service": "Serviço bloqueado",
|
||||
"blocked_services": "Serviços bloqueados",
|
||||
"blocked_threats": "Ameaças bloqueadas",
|
||||
"blocked_threats_chart": "Ameaças bloqueadas",
|
||||
"blocklist_add_manual": "Adicionar manualmente",
|
||||
"blocklist_edit": "Editar lista de bloqueio",
|
||||
"blocklist_homepage": "Página inicial",
|
||||
"blocklist_url_file_path": "URL ou caminho de ficheiro",
|
||||
"blocklists_add": "Adicionar lista de bloqueio",
|
||||
"bootstrap_dns": "Servidores DNS de arranque",
|
||||
"cache_cleared": "O cache DNS foi apagado com sucesso",
|
||||
"cache_config_clear": "Limpar cache",
|
||||
"cache_config_optimistic": "Cache otimista",
|
||||
"cache_config_ttl_validation": "O substituto mínimo de cache TTL deve ser menor ou igual ao máximo",
|
||||
"cache_enabled": "Ativar cache",
|
||||
"cancel": "Cancelar",
|
||||
"cancel_btn": "Cancelar",
|
||||
"category_tooltip": "Categoria",
|
||||
"changes_saved_success": "Alterações guardadas",
|
||||
"check_dhcp_servers": "Verificar por servidores DHCP",
|
||||
"check_not_found": "Não encontrado nas tuas listas de filtros",
|
||||
"check_updates_btn": "Verificar atualizações",
|
||||
"clear_btn": "Limpar",
|
||||
"client_add": "Adicionar cliente",
|
||||
"client_details": "Detalhes do cliente",
|
||||
"client_id": "ID do cliente",
|
||||
"client_id_placeholder": "Insira o ID do cliente",
|
||||
"client_identifier": "Identificador",
|
||||
"client_settings": "Definições do cliente",
|
||||
"client_table_header": "Cliente",
|
||||
"clients_add_identifier": "Adicionar identificador",
|
||||
"clients_dns_cache_size": "Tamanho da cache DNS, em bytes",
|
||||
"clients_not_found": "Nenhum cliente foi encontrado",
|
||||
"clients_protection": "Proteção",
|
||||
"clients_tags": "Etiquetas",
|
||||
"clients_title": "Clientes persistentes",
|
||||
"clients_use_global_settings": "Usar definições globais",
|
||||
"close": "Fechar",
|
||||
"confirm": "Confirmar",
|
||||
"copied": "Copiado para a área de transferência",
|
||||
"copy": "Copiar",
|
||||
"custom_filter_rules": "Regras de filtragem personalizadas",
|
||||
"custom_filtering_rules": "Regras de filtragem personalizadas",
|
||||
"dark_theme": "Tema escuro",
|
||||
"dashboard": "Painel",
|
||||
"delete_btn": "Apagar",
|
||||
"delete_table_action": "Apagar",
|
||||
"dhcp_add_static_lease": "Adicionar nova concessão estática",
|
||||
"dhcp_config_saved": "Definições DHCP guardadas com sucesso",
|
||||
"dhcp_edit_static_lease": "Editar concessão estática",
|
||||
"dhcp_form_gateway_input": "IP do gateway",
|
||||
"dhcp_form_lease_title": "Tempo de concessão do DHCP (em segundos)",
|
||||
"dhcp_form_subnet_input": "Máscara de sub-rede",
|
||||
"dhcp_hardware_address": "Endereço de hardware",
|
||||
"dhcp_interface_select": "Selecione a interface DHCP",
|
||||
"dhcp_ip_addresses": "Endereços de IP",
|
||||
"dhcp_leases_not_found": "Nenhuma concessão DHCP encontrada",
|
||||
"dhcp_new_static_lease": "Nova concessão estática",
|
||||
"dhcp_reset_leases": "Repor todas as concessões",
|
||||
"dhcp_reset_leases_success": "Concessões de DHCP repostas com sucesso",
|
||||
"dhcp_settings": "Definições de DHCP",
|
||||
"dhcp_static_leases_not_found": "Nenhuma concessão DHCP estática foi encontrada",
|
||||
"dhcp_table_hostname": "Nome do servidor",
|
||||
"dhcp_table_ip_address": "Endereço de IP",
|
||||
"dhcp_warning": "Se tu quiser ativar o servidor DHCP de qualquer maneira, certifique-se de que não haja outro servidor DHCP ativo em tua rede, pois isso pode quebrar a conectividade com a Internet para dispositivos na rede!",
|
||||
"disable": "Desativar",
|
||||
"disable_notify_until_tomorrow": "Desativar a proteção até amanhã",
|
||||
"disable_protection_btn": "Desativar proteção",
|
||||
"disabled_dhcp": "Servidor DHCP desativado",
|
||||
"disabled_protection": "Desativar proteção",
|
||||
"disallow_this_client": "Não permitir este cliente",
|
||||
"dns_cache_config": "Definição de cache DNS",
|
||||
"dns_config": "Definição do servidor DNS",
|
||||
"dns_over_https": "DNS-sobre-HTTPS",
|
||||
"dns_over_quic": "DNS-sobre-QUIC",
|
||||
"dns_over_tls": "DNS-sobre-TLS",
|
||||
"dns_privacy": "Privacidade de DNS",
|
||||
"dns_query": "Consultas de DNS",
|
||||
"dns_rewrites": "Reescritas de DNS",
|
||||
"dns_settings": "Definições de DNS",
|
||||
"dns_test_ok_toast": "Os servidores DNS especificados estão a funcionar corretamente",
|
||||
"domain": "Domínio",
|
||||
"domain_or_client": "Domínio ou cliente",
|
||||
"down": "Caiu",
|
||||
"download_mobileconfig": "Transferir ficheiro de configuração",
|
||||
"edit_table_action": "Editar",
|
||||
"enable": "Ativar",
|
||||
"enabled_dhcp": "Servidor DHCP ativado",
|
||||
"enabled_protection": "Ativar proteção",
|
||||
"enabled_table_header": "Ativado",
|
||||
"encryption_certificate_path": "Caminho do certificado",
|
||||
"encryption_certificates": "Certificados",
|
||||
"encryption_certificates_input": "Copie/cole aqui o seu certificado codificado em PEM.",
|
||||
"encryption_certificates_source_content": "Colar o conteúdo dos certificados",
|
||||
"encryption_certificates_source_path": "Definir um caminho do ficheiro de certificados",
|
||||
"encryption_chain_invalid": "A cadeia de certificado é inválida",
|
||||
"encryption_chain_valid": "Cadeia de certificado válida",
|
||||
"encryption_config_saved": "Definição de criptografia guardada",
|
||||
"encryption_doq": "Porta DNS-sobre-QUIC",
|
||||
"encryption_dot": "Porta DNS-sobre-TLS",
|
||||
"encryption_https": "Porta HTTPS",
|
||||
"encryption_key": "Chave privada",
|
||||
"encryption_key_input": "Copie/cole aqui a chave privada codificada em PEM para o seu certificado.",
|
||||
"encryption_key_source_content": "Colar o conteúdo da chave privada",
|
||||
"encryption_plain_dns": "DNS simples",
|
||||
"encryption_plain_dns_error": "Para desabilitar o DNS simples, habilite pelo menos um protocolo DNS criptografado",
|
||||
"encryption_private_key_path": "Caminho da chave privada",
|
||||
"encryption_server": "Nome do servidor",
|
||||
"encryption_server_enter": "Insira o seu nome de domínio",
|
||||
"encryption_title": "Encriptação",
|
||||
"enter_cache_size": "Digite o tamanho do cache (bytes)",
|
||||
"enter_ip_address_placeholder": "Digite o endereço de IP",
|
||||
"error": "Erro",
|
||||
"fallback_dns": "Servidores DNS de fallback",
|
||||
"filter_category_general": "Geral",
|
||||
"filter_category_general_desc": "Listas que bloqueiam o monitorização e a publicidade na maioria dos dispositivos",
|
||||
"filter_category_other": "Noutro",
|
||||
"filter_category_other_desc": "Outras listas de bloqueio",
|
||||
"filter_category_regional": "Regional",
|
||||
"filter_category_regional_desc": "Listas focadas em anúncios regionais e servidores de monitorização",
|
||||
"filter_category_security": "Segurança",
|
||||
"filter_category_security_desc": "Listas projetadas especificamente em bloquear domínios maliciosos, de phishing e fraude",
|
||||
"filter_removed_successfully": "A lista foi removida com sucesso",
|
||||
"filters": "Filtros",
|
||||
"forgot_password": "Não se lembra da palavra-passe?",
|
||||
"form_enter_blocked_response_ttl": "Insira o TTL da resposta bloqueada (segundos)",
|
||||
"form_error_answer_format": "Formato de resposta inválido",
|
||||
"form_error_client_id_format": "O ID do cliente deve conter apenas números, letras minúsculas e hifens",
|
||||
"form_error_domain_format": "Formato de domínio inválido",
|
||||
"form_error_equal": "Não deve ser igual",
|
||||
"form_error_gateway_ip": "A concessão não pode ter o endereço IP do gateway",
|
||||
"form_error_ip4_format": "Endereço de IPv4 inválido",
|
||||
"form_error_ip6_format": "Endereço de IPv6 inválido",
|
||||
"form_error_ip_format": "Endereço de email inválido",
|
||||
"form_error_mac_format": "Endereço de MAC inválido",
|
||||
"form_error_password": "As palavras-passe não coincidem",
|
||||
"form_error_port": "Insira um número de porta válida",
|
||||
"form_error_port_range": "Digite um numero de porta entre 80 e 65535",
|
||||
"form_error_port_unsafe": "Porta não é segura",
|
||||
"form_error_required": "Por favor, preencha este campo",
|
||||
"form_error_server_name": "Nome de servidor inválido",
|
||||
"form_error_url_or_path_format": "URL ou local da lista inválida",
|
||||
"friday": "Sexta-feira",
|
||||
"gateway_or_subnet_invalid": "Máscara de sub-rede inválida",
|
||||
"general_settings": "Definições gerais",
|
||||
"general_statistics": "Estatísticas gerais",
|
||||
"greater_range_start_error": "Deve ser maior que o início do intervalo",
|
||||
"ignore_domains_desc_query": "As consultas que correspondem a essas regras não são gravadas no registo de consultas",
|
||||
"ignore_domains_title": "Domínios ignorados",
|
||||
"inactivity_schedule_add": "Adicionar agendamento",
|
||||
"inactivity_schedule_edit": "Editar agendamento",
|
||||
"install_auth_confirm": "Confirmar palavra-passe",
|
||||
"install_auth_password": "Palavra-passe",
|
||||
"install_auth_password_enter": "Insira palavra-passe",
|
||||
"install_auth_username": "Nome do utilizador",
|
||||
"install_auth_username_enter": "Insira o nome de utilizador",
|
||||
"install_saved": "Guardado com sucesso",
|
||||
"install_settings_all_interfaces": "Todas as interfaces",
|
||||
"install_settings_port": "Porta",
|
||||
"install_static_error": "O AdGuard Home não pode configurar automaticamente para esta interface de rede. Por favor, procure uma instrução sobre como fazer isso manualmente.",
|
||||
"install_static_ok": "Boas notícias! O endereço de IP estático já está configurado",
|
||||
"install_step": "Passo",
|
||||
"install_submit_title": "Parabéns!",
|
||||
"ip_address": "Endereço de IP",
|
||||
"known_tracker": "Rastreador conhecido",
|
||||
"light_theme": "Tema claro",
|
||||
"login": "Iniciar sessão",
|
||||
"logout": "Encerrar sessão",
|
||||
"make_static": "Tornar estático",
|
||||
"milliseconds_abbreviation": "ms",
|
||||
"monday": "Segunda-feira",
|
||||
"name": "Nome",
|
||||
"name_label": "Nome",
|
||||
"name_table_header": "Nome",
|
||||
"name_tooltip": "Nome",
|
||||
"next": "Seguinte",
|
||||
"no": "Não",
|
||||
"nothing_found": "Nada encontrado",
|
||||
"notify_undo": "Anular",
|
||||
"notify_updated": "Atualizado",
|
||||
"parental_control": "Controlo parental",
|
||||
"parental_group_cdn": "CDN",
|
||||
"parental_group_development": "Desenvolvimento de software",
|
||||
"parental_group_gaming": "Jogos",
|
||||
"parental_group_hosting": "Alojamento web",
|
||||
"parental_group_privacy": "Ferramentas de privacidade",
|
||||
"parental_group_shopping": "Compras",
|
||||
"parental_group_social": "Redes sociais",
|
||||
"parental_group_social_network": "Redes sociais",
|
||||
"parental_group_software": "Desenvolvimento de software",
|
||||
"parental_group_streaming": "Streaming",
|
||||
"password_label": "Palavra-passe",
|
||||
"password_placeholder": "Insira palavra-passe",
|
||||
"plain_dns": "DNS simples",
|
||||
"privacy_policy": "Política de privacidade",
|
||||
"protection": "Proteção",
|
||||
"protocol": "Protocolo",
|
||||
"query_log_allowed": "Na lista branca",
|
||||
"query_log_blocked": "Bloqueado",
|
||||
"query_log_blocked_services": "Serviços bloqueados",
|
||||
"query_log_blocked_threats": "Ameaças bloqueadas",
|
||||
"query_log_custom_filtering_rules": "Regras de filtragem personalizadas",
|
||||
"query_log_processed": "Processado",
|
||||
"query_log_retention": "Rotação de registros de consulta",
|
||||
"query_log_rewritten": "Reescrito",
|
||||
"query_log_safe_search": "Pesquisa segura",
|
||||
"query_log_strict_search": "Usar aspas duplas para uma pesquisa rigorosa",
|
||||
"rate_limit_subnet_len_ipv4_error": "O comprimento do prefixo da sub-rede IPv4 deve estar entre 0 e 32",
|
||||
"rate_limit_subnet_len_ipv6_error": "O comprimento do prefixo da sub-rede IPv6 deve situar-se entre 0 e 128",
|
||||
"refresh_btn": "Atualizar",
|
||||
"refresh_statics": "Atualizar estatísticas",
|
||||
"release_notes": "Notas de lançamento",
|
||||
"remove": "Remover",
|
||||
"report_an_issue": "Comunicar um problema",
|
||||
"request_table_header": "Solicitação",
|
||||
"requests_table_header": "Solicitações",
|
||||
"reset": "Repor",
|
||||
"reset_settings": "Repor definições",
|
||||
"reset_settings_confirm": "Sim, repor",
|
||||
"response_details": "Detalhes da resposta",
|
||||
"response_time": "Tempo de resposta",
|
||||
"result": "Resultado",
|
||||
"rewrite_add": "Adicionar reescrita de DNS",
|
||||
"rewrite_edit": "Editar reedição de DNS",
|
||||
"rewrite_hosts_applied": "Reescrito pela regra do ficheiro de hosts",
|
||||
"rewrite_settings_updated": "Definições de reescrita de DNS actualizadas com sucesso",
|
||||
"rewrite_updated": "Reedição de DNS atualizada com sucesso",
|
||||
"rewritten": "Reescrito",
|
||||
"rules_label": "Regras",
|
||||
"safe_browsing": "Navegação segura",
|
||||
"safe_search": "Pesquisa segura",
|
||||
"saturday": "Sábado",
|
||||
"save": "Guardar",
|
||||
"save_btn": "Guardar",
|
||||
"search_placeholder": "Pesquisar",
|
||||
"server_config_blocking_mode": "Modo de bloqueio",
|
||||
"server_config_blocking_mode_ipv4": "A bloquear IPv4",
|
||||
"server_config_blocking_mode_ipv6": "A bloquear IPv6",
|
||||
"server_config_custom_ip": "Endereço IP personalizado",
|
||||
"server_config_default": "Predefinido",
|
||||
"server_config_disable_ipv6": "Desativar resolução de endereços IPv6",
|
||||
"server_config_dnssec_enable": "Ativar DNSSEC",
|
||||
"server_config_edns_enable": "Ativar a sub-rede do cliente EDNS",
|
||||
"server_config_nxdomain": "NXDOMAIN",
|
||||
"server_config_rate_limit_placeholder": "Insira o limite de velocidade",
|
||||
"server_config_rate_limit_whitelist_faq": "Endereços IP excluídos da limitação de velocidade",
|
||||
"server_config_refused": "REFUSED",
|
||||
"server_config_subnet_len_ipv4": "Comprimento do prefixo de sub-rede para endereços IPv4",
|
||||
"server_config_subnet_len_ipv4_faq": "Comprimento do prefixo de sub-rede para endereços IPv4 usados para limitação de velocidade. O padrão é 24",
|
||||
"server_config_subnet_len_ipv6": "Comprimento do prefixo de sub-rede para endereços IPv6",
|
||||
"server_config_subnet_len_ipv6_faq": "Comprimento do prefixo de sub-rede para endereços IPv6 usados para limitação de velocidade. O padrão é 56",
|
||||
"set_static_ip": "Definir um endereço de IP estático",
|
||||
"settings": "Definições",
|
||||
"settings_browsing_security": "Segurança da Navegação",
|
||||
"settings_confirm_clear_statistics": "Limpar estatísticas?",
|
||||
"settings_custom": "Personalizado",
|
||||
"settings_general_short": "Geral",
|
||||
"settings_global": "Global",
|
||||
"settings_notify_changes_saved": "Alterações guardadas",
|
||||
"settings_notify_statistics_cleared": "Estatísticas apagadas",
|
||||
"settings_parental_control": "Controlo parental",
|
||||
"settings_rule_syntax": "Sintaxe de regras",
|
||||
"settings_safe_search": "Pesquisa segura",
|
||||
"settings_statistics": "Estatísticas",
|
||||
"settings_statistics_clear": "Limpar estatísticas",
|
||||
"settings_statistics_retention": "Retenção de estatísticas",
|
||||
"settings_yes_clear": "Sim, limpar",
|
||||
"setup_devices_router_title": "Router",
|
||||
"setup_dns_privacy_ioc_mac": "configuração para iOS e macOS",
|
||||
"setup_dns_privacy_other_title": "Outras implementações",
|
||||
"show_blocked_responses": "Bloqueado",
|
||||
"show_processed_responses": "Processado",
|
||||
"source_label": "Fonte",
|
||||
"source_tooltip": "Fonte",
|
||||
"stats_adult": "Sítios adultos bloqueados",
|
||||
"stats_query_domain": "Principais domínios consultados",
|
||||
"status_table_header": "Estado",
|
||||
"subnet_error": "Os endereços devem estar numa sub-rede",
|
||||
"sunday": "Domingo",
|
||||
"system_host_files": "Arquivos hosts do sistema",
|
||||
"table_client": "Cliente",
|
||||
"tags_title": "Etiquetas",
|
||||
"test_upstreams": "Testar DNS primário",
|
||||
"threats_blocked": "Ameaças bloqueadas",
|
||||
"thursday": "Quinta-feira",
|
||||
"time_table_header": "Data",
|
||||
"top_blocked_domains": "Principais domínios bloqueados",
|
||||
"top_clients": "Principais clientes",
|
||||
"top_upstreams": "Melhores servidores DNS primários",
|
||||
"total": "Total",
|
||||
"try_again": "Tente novamente",
|
||||
"tuesday": "Terça-feira",
|
||||
"unavailable_dhcp": "DHCP não está disponível",
|
||||
"unavailable_dhcp_desc": "O AdGuard Home não pode executar um servidor DHCP em seu sistema operacional",
|
||||
"unblock": "Desbloquear",
|
||||
"update_filters_disable": "Desativar",
|
||||
"update_filters_interval_custom": "Personalizado",
|
||||
"update_filters_interval_daily": "Diariamente",
|
||||
"update_filters_interval_hourly": "Cada %s hora",
|
||||
"update_filters_title": "Atualizar automaticamente os filtros",
|
||||
"updated_custom_filtering_toast": "Regras personalizadas guardadas com sucesso",
|
||||
"updated_upstream_dns_toast": "Servidores DNS primário guardados com sucesso",
|
||||
"updates_checked": "Uma nova versão do AdGuard Home está disponível\n",
|
||||
"updates_version_equal": "O AdGuard Home está atualizado",
|
||||
"upstream": "Servidor DNS primário",
|
||||
"upstream_dns": "Servidores DNS primário",
|
||||
"upstream_dns_addresses": "Endereços de servidor, um por linha",
|
||||
"upstream_dns_fastest_addr": "Endereço de IP mais rápido",
|
||||
"upstream_dns_load_balancing": "Balanceamento de carga",
|
||||
"upstream_dns_parallel_requests": "Solicitações paralelas",
|
||||
"upstream_dns_servers_title": "Servidores DNS primário",
|
||||
"upstream_examples_title": "Exemplos",
|
||||
"upstream_private_ptr_resolvers_title": "Usar resolvedores DNS reversos privados",
|
||||
"upstreams": "DNS primário",
|
||||
"url_label": "URL",
|
||||
"use_saved_key": "Use a chave guardada anteriormente",
|
||||
"user_rules": "Regras do utilizador",
|
||||
"user_rules_add_to_allowlist": "Adicionar na lista de permissões",
|
||||
"user_rules_check_button": "Verificar",
|
||||
"user_rules_check_client_placeholder": "Insira o identificador do cliente",
|
||||
"user_rules_check_hostname_label": "Nome do hospedeiro ou nome de domínio",
|
||||
"user_rules_disable_filter": "Desativar filtro",
|
||||
"user_rules_disable_parental_control": "Desativar controlo parental",
|
||||
"user_rules_dns_record_type_placeholder": "Selecione o tipo de registro DNS",
|
||||
"user_rules_domain_blocked": "O domínio está bloquado",
|
||||
"user_rules_edit_dns_rewrite": "Editar reedição de DNS",
|
||||
"user_rules_rewrite_rule_is_applied": "Regra de reescrita aplicada",
|
||||
"user_rules_title": "Regras de filtragem personalizadas",
|
||||
"username_label": "Nome do utilizador",
|
||||
"username_placeholder": "Insira o nome de utilizador",
|
||||
"validated_with_dnssec": "Validado com DNSSEC",
|
||||
"wednesday": "Quarta-feira",
|
||||
"whois": "WHOIS",
|
||||
"yes": "Sim"
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
{
|
||||
"access_settings_title": "Настройки доступа",
|
||||
"actions_label": "Действия",
|
||||
"actions_table_header": "Действия",
|
||||
"add": "Добавить",
|
||||
"add_allowlist": "Добавить белый список",
|
||||
"add_blocklist": "Добавить чёрный список",
|
||||
"add_persistent_client": "Добавить в сохранённые клиенты",
|
||||
"add_to_allowlist": "Добавить в белый список",
|
||||
"ads_blocked": "Заблокировано баннеров",
|
||||
"ads_blocked_card": "Заблокировано баннеров",
|
||||
"adult_websites_blocked_tooltip": "Количество заблокированных «сайтов для взрослых»",
|
||||
"all_lists_up_to_date_toast": "Все списки уже обновлены",
|
||||
"allowed": "Разрешённые",
|
||||
"allowlist_add": "Добавить белый список",
|
||||
"allowlist_edit": "Редактировать белый список",
|
||||
"allowlists": "Белые списки",
|
||||
"allowlists_title": "Белые списки",
|
||||
"apply": "Применить",
|
||||
"auto_clients_title": "Клиенты (runtime)",
|
||||
"autofix_warning_result": "В результате все DNS-запросы от вашей системы будут по умолчанию обрабатываться AdGuard Home.\n",
|
||||
"autofix_warning_text": "При нажатии «Исправить» AdGuard Home настроит вашу систему на использование DNS-сервера AdGuard Home.",
|
||||
"average_time_processing": "Среднее время обработки запроса",
|
||||
"average_upstream_response_time": "Среднее время ответа upstream-сервера",
|
||||
"back": "Назад",
|
||||
"block": "Заблокировать",
|
||||
"block_for_this_client_only": "Заблокировать только для этого клиента",
|
||||
"blocked": "Заблокировано",
|
||||
"blocked_by_cname_or_ip": "Заблокировано с помощью CNAME или IP",
|
||||
"blocked_service": "Заблокированный сервис",
|
||||
"blocked_services": "Заблокированные сервисы",
|
||||
"blocked_threats": "Заблокировано угроз",
|
||||
"blocked_threats_chart": "Заблокировано угроз",
|
||||
"blocklist_add_manual": "Добавить",
|
||||
"blocklist_edit": "Редактировать чёрный список",
|
||||
"blocklist_homepage": "Сайт",
|
||||
"blocklist_url_file_path": "URL или путь к файлу",
|
||||
"blocklists_add": "Добавить чёрный список",
|
||||
"bootstrap_dns": "Bootstrap DNS-серверы",
|
||||
"cache_cleared": "Кеш DNS успешно очищен",
|
||||
"cache_config_clear": "Очистить кеш",
|
||||
"cache_config_optimistic": "Оптимистическое кеширование",
|
||||
"cache_config_ttl_validation": "Значение для переопределения минимального TTL должно быть меньше или равно значению для переопределения максимального",
|
||||
"cache_enabled": "Включить кеш",
|
||||
"cancel": "Отмена",
|
||||
"cancel_btn": "Отмена",
|
||||
"category_tooltip": "Категория",
|
||||
"changes_saved_success": "Изменения сохранены",
|
||||
"check_dhcp_servers": "Проверить DHCP-серверы",
|
||||
"check_not_found": "Не найдено в вашем списке фильтров",
|
||||
"check_updates_btn": "Проверить обновления",
|
||||
"clear_btn": "Очистить",
|
||||
"client_add": "Добавить клиента",
|
||||
"client_details": "Информация о клиенте",
|
||||
"client_id": "ClientID",
|
||||
"client_id_placeholder": "Введите ClientID",
|
||||
"client_identifier": "Идентификатор",
|
||||
"client_settings": "Настройки клиентов",
|
||||
"client_table_header": "Клиент",
|
||||
"clients": "Клиенты",
|
||||
"clients_add_identifier": "Добавить идентификатор",
|
||||
"clients_dns_cache_size": "Размер DNS-кеша в байтах",
|
||||
"clients_dns_cache_size_placeholder": "Используйте числа",
|
||||
"clients_dont_collect_stats": "Не собирать статистику для этого клиента",
|
||||
"clients_dont_log": "Не записывать этого клиента в журнал",
|
||||
"clients_identifier_already_used": "Этот идентификатор уже используется",
|
||||
"clients_identifier_format_error": "Используйте цифры, строчные латинские буквы или дефисы",
|
||||
"clients_logs_and_statistics": "Журнал запросов и статистика",
|
||||
"clients_not_found": "Клиентов не найдено",
|
||||
"clients_protection": "Защита",
|
||||
"clients_protection_desc": "Включить или отключить функции защиты, записи в журнал и статистику",
|
||||
"clients_tags": "Теги",
|
||||
"clients_tags_desc": "Вы можете добавить теги для этого клиента и использовать их в правилах фильтрации. <a>Подробнее</a>",
|
||||
"clients_title": "Сохранённые клиенты",
|
||||
"clients_upstreams_desc": "Upstream-серверы DNS, по одному на строку",
|
||||
"clients_use_dns_cache": "Использовать кэш DNS",
|
||||
"clients_use_global_settings": "Использовать глобальные настройки",
|
||||
"clients_use_global_settings_desc": "Использовать настройки защиты из вашего аккаунта AdGuard Home",
|
||||
"close": "Закрыть",
|
||||
"confirm": "Подтвердить",
|
||||
"copied": "Скопировано",
|
||||
"copy": "Копировать",
|
||||
"custom_filter_rules": "Пользовательские правила фильтрации",
|
||||
"custom_filtering_rules": "Пользовательские правила фильтрации",
|
||||
"dark_theme": "Тёмная тема",
|
||||
"dashboard": "Панель управления",
|
||||
"delete_btn": "Удалить",
|
||||
"delete_table_action": "Удалить",
|
||||
"dhcp_add_static_lease": "Добавить статическую аренду",
|
||||
"dhcp_config_saved": "Конфигурация DHCP-сервера успешно сохранена",
|
||||
"dhcp_edit_static_lease": "Редактирование статической аренды",
|
||||
"dhcp_form_gateway_input": "IP-адрес шлюза",
|
||||
"dhcp_form_lease_title": "Время аренды DHCP (в секундах)",
|
||||
"dhcp_form_subnet_input": "Маска подсети",
|
||||
"dhcp_hardware_address": "Аппаратный адрес",
|
||||
"dhcp_interface_select": "Выбрать интерфейс DHCP",
|
||||
"dhcp_ip_addresses": "IP-адреса",
|
||||
"dhcp_leases_not_found": "Аренда DHCP не обнаружена",
|
||||
"dhcp_new_static_lease": "Новая статическая аренда",
|
||||
"dhcp_reset_leases": "Сбросить все аренды",
|
||||
"dhcp_reset_leases_success": "Аренды DHCP успешно удалены",
|
||||
"dhcp_settings": "Настройки DHCP",
|
||||
"dhcp_static_leases_not_found": "Не найдено статических аренд DHCP",
|
||||
"dhcp_table_hostname": "Имя хоста",
|
||||
"dhcp_table_ip_address": "IP-адрес",
|
||||
"dhcp_warning": "Если вы всё равно хотите включить DHCP-сервер, убедитесь, что в сети больше нет активных DHCP-серверов. Иначе это может сломать доступ в сеть для подключённых устройств!",
|
||||
"disable": "Отключить",
|
||||
"disable_notify_for_hours": "| Отключить защиту на %count% час | Отключить защиту на %count% часа | Отключить защиту на %count% часов | Отключить защиту на %count% часа",
|
||||
"disable_notify_for_minutes": "Отключить защиту на %count% минуту|Отключить защиту на %count% минуты|Отключить защиту на %count% минут|Отключить защиту на %count% минуты",
|
||||
"disable_notify_for_seconds": "| Отключить защиту на %value% секунду | Отключить защиту на %value% секунды | Отключить защиту на %value% секунд | Отключить защиту на %value% секунды",
|
||||
"disable_notify_until_tomorrow": "Отключить защиту до завтра",
|
||||
"disable_protection_btn": "Отключить защиту",
|
||||
"disabled_dhcp": "DHCP-сервер отключён",
|
||||
"disabled_protection": "Защита выкл.",
|
||||
"disallow_this_client": "Запретить доступ клиенту",
|
||||
"dns_cache_config": "Настройка кеша DNS",
|
||||
"dns_config": "Настройки DNS-сервера",
|
||||
"dns_over_https": "DNS-over-HTTPS",
|
||||
"dns_over_quic": "DNS-over-QUIC",
|
||||
"dns_over_tls": "DNS-over-TLS",
|
||||
"dns_privacy": "Зашифрованный DNS",
|
||||
"dns_query": "DNS-запросы",
|
||||
"dns_rewrites": "Перезапись DNS-запросов",
|
||||
"dns_settings": "Настройки DNS",
|
||||
"dns_test_ok_toast": "Указанные серверы DNS работают корректно",
|
||||
"domain": "Домен",
|
||||
"domain_or_client": "Домен или клиент",
|
||||
"down": "Вниз",
|
||||
"download_mobileconfig": "Загрузить файл конфигурации",
|
||||
"edit_table_action": "Редактировать",
|
||||
"enable": "Включить",
|
||||
"enabled_dhcp": "DHCP-сервер включён",
|
||||
"enabled_protection": "Защита вкл.",
|
||||
"enabled_table_header": "Включено",
|
||||
"encryption_certificate_path": "Путь к сертификату",
|
||||
"encryption_certificates": "Сертификаты",
|
||||
"encryption_certificates_input": "Скопируйте сюда сертификаты в PEM-кодировке.",
|
||||
"encryption_certificates_source_content": "Вставить содержимое сертификатов",
|
||||
"encryption_certificates_source_path": "Указать путь к файлу сертификатов",
|
||||
"encryption_chain_invalid": "Цепочка сертификатов не прошла проверку",
|
||||
"encryption_chain_valid": "Цепочка сертификатов прошла проверку",
|
||||
"encryption_config_saved": "Настройки шифрования сохранены",
|
||||
"encryption_doq": "Порт DNS-over-QUIC",
|
||||
"encryption_dot": "Порт DNS-over-TLS",
|
||||
"encryption_encrypted_dns": "Зашифрованный DNS",
|
||||
"encryption_https": "Порт HTTPS",
|
||||
"encryption_key": "Приватный ключ",
|
||||
"encryption_key_input": "Скопируйте сюда приватный ключ в PEM-кодировке.",
|
||||
"encryption_key_source_content": "Вставить содержимое закрытого ключа",
|
||||
"encryption_plain_dns": "Нешифрованный DNS",
|
||||
"encryption_plain_dns_desc": "Включён по умолчанию. Вы можете отключить его, чтобы заставить все устройства использовать зашифрованный DNS. Для этого укажите как минимум один зашифрованный DNS-протокол",
|
||||
"encryption_plain_dns_error": "Чтобы отключить незашифрованный DNS, включите хотя бы один зашифрованный протокол DNS",
|
||||
"encryption_private_key_path": "Путь к закрытому ключу",
|
||||
"encryption_server": "Имя сервера",
|
||||
"encryption_server_enter": "Введите ваше доменное имя",
|
||||
"encryption_title": "Шифрование",
|
||||
"enter_cache_size": "Введите размер кеша (в байтах)",
|
||||
"enter_ip_address_placeholder": "Введите IP-адрес",
|
||||
"error": "Ошибка",
|
||||
"fallback_dns": "Резервные DNS-серверы",
|
||||
"filter_category_general": "General",
|
||||
"filter_category_general_desc": "Списки, которые блокируют отслеживание и рекламу на большинстве устройств",
|
||||
"filter_category_other": "Прочее",
|
||||
"filter_category_other_desc": "Другие списки блокировки",
|
||||
"filter_category_regional": "Региональные",
|
||||
"filter_category_regional_desc": "Списки, которые фокусируются на региональной рекламе и серверах отслеживания",
|
||||
"filter_category_security": "Безопасность",
|
||||
"filter_category_security_desc": "Списки, созданные специально для блокировки вредоносных, фишинговых и мошеннических доменов",
|
||||
"filter_removed_successfully": "Список успешно удалён",
|
||||
"filters": "Фильтры",
|
||||
"forgot_password": "Забыли пароль?",
|
||||
"form_enter_blocked_response_ttl": "Введите TTL заблокированного ответа (в секундах)",
|
||||
"form_error_answer_format": "Некорректный ответ",
|
||||
"form_error_client_id_format": "ClientID может содержать только цифры, строчные латинские буквы и дефисы",
|
||||
"form_error_domain_format": "Некорректный домен",
|
||||
"form_error_equal": "Не должны быть равны",
|
||||
"form_error_gateway_ip": "Аренда не может иметь IP-адрес шлюза",
|
||||
"form_error_ip4_format": "Некорректный IPv4-адрес",
|
||||
"form_error_ip6_format": "Некорректный IPv6-адрес",
|
||||
"form_error_ip_format": "Некорректный IP-адрес",
|
||||
"form_error_mac_format": "Некорректный MAC-адрес",
|
||||
"form_error_password": "Пароли не совпадают",
|
||||
"form_error_port": "Введите корректный порт",
|
||||
"form_error_port_range": "Введите номер порта из интервала 80-65535",
|
||||
"form_error_port_unsafe": "Небезопасный порт",
|
||||
"form_error_required": "Это поле нужно заполнить",
|
||||
"form_error_server_name": "Некорректное имя сервера",
|
||||
"form_error_url_or_path_format": "Неверный URL или абсолютный путь к списку",
|
||||
"friday": "Пятница",
|
||||
"gateway_or_subnet_invalid": "Некорректная маска подсети",
|
||||
"general_settings": "Основные настройки",
|
||||
"general_statistics": "Общая статистика",
|
||||
"greater_range_start_error": "Должно быть больше начала диапазона",
|
||||
"ignore_domains_desc_query": "Запросы, соответствующие этим правилам, не записываются в журнал запросов",
|
||||
"ignore_domains_title": "Игнорируемые домены",
|
||||
"inactivity_schedule_add": "Добавить расписание",
|
||||
"inactivity_schedule_edit": "Изменить расписание",
|
||||
"install_auth_confirm": "Подтвердить пароль",
|
||||
"install_auth_password": "Пароль",
|
||||
"install_auth_password_enter": "Введите пароль",
|
||||
"install_auth_username": "Имя пользователя",
|
||||
"install_auth_username_enter": "Введите имя пользователя",
|
||||
"install_saved": "Успешно сохранено",
|
||||
"install_settings_all_interfaces": "Все интерфейсы",
|
||||
"install_settings_port": "Порт",
|
||||
"install_static_error": "AdGuard Home не может автоматически настроить его для этого сетевого интерфейса. Пожалуйста, посмотрите инструкцию о том, как это сделать вручную.",
|
||||
"install_static_ok": "Хорошие новости! Ваш статический IP-адрес уже настроен",
|
||||
"install_step": "Шаг",
|
||||
"install_submit_title": "Поздравляем!",
|
||||
"ip_address": "IP-адрес",
|
||||
"known_tracker": "Известный трекер",
|
||||
"light_theme": "Светлая тема",
|
||||
"login": "Войти",
|
||||
"logout": "Выйти",
|
||||
"make_static": "Сделать статической",
|
||||
"milliseconds_abbreviation": "мс",
|
||||
"monday": "Понедельник",
|
||||
"name": "Название",
|
||||
"name_label": "Название",
|
||||
"name_table_header": "Название",
|
||||
"name_tooltip": "Название",
|
||||
"next": "Далее",
|
||||
"no": "Нет",
|
||||
"nothing_found": "Ничего не найдено",
|
||||
"notify_undo": "Отменить",
|
||||
"notify_updated": "Обновлено",
|
||||
"parental_control": "Родительский контроль",
|
||||
"parental_group_cdn": "CDN",
|
||||
"parental_group_development": "Разработка программного обеспечения",
|
||||
"parental_group_gaming": "Игры",
|
||||
"parental_group_hosting": "Веб-хостинг",
|
||||
"parental_group_privacy": "Инструменты конфиденциальности",
|
||||
"parental_group_shopping": "Шопинг",
|
||||
"parental_group_social": "Социальные сети",
|
||||
"parental_group_social_network": "Социальные сети",
|
||||
"parental_group_software": "Разработка программного обеспечения",
|
||||
"parental_group_streaming": "Доступен стриминг",
|
||||
"password_label": "Пароль",
|
||||
"password_placeholder": "Введите пароль",
|
||||
"plain_dns": "Нешифрованный DNS",
|
||||
"privacy_policy": "Политика конфиденциальности",
|
||||
"protection": "Защита",
|
||||
"protocol": "Протокол",
|
||||
"query_log_allowed": "Разрешённые",
|
||||
"query_log_blocked": "Заблокировано",
|
||||
"query_log_blocked_services": "Заблокированные сервисы",
|
||||
"query_log_blocked_threats": "Заблокировано угроз",
|
||||
"query_log_custom_filtering_rules": "Пользовательские правила фильтрации",
|
||||
"query_log_detail_protocol": "Протокол: <span>%value%</span>",
|
||||
"query_log_nothing_available_rotation": "Данных нет. Чтобы собирать запросы, измените период хранения записей в журнале в <a>Основных настройках</a>",
|
||||
"query_log_processed": "Обработан",
|
||||
"query_log_retention": "Частота ротации журнала запросов",
|
||||
"query_log_rewritten": "Перезаписан",
|
||||
"query_log_safe_search": "Безопасный поиск",
|
||||
"query_log_strict_search": "Используйте двойные кавычки для строгого поиска",
|
||||
"rate_limit_subnet_len_ipv4_error": "Длина префикса IPv4-подсетей должна составлять от 0 до 32",
|
||||
"rate_limit_subnet_len_ipv6_error": "Длина префикса IPv6-подсетей должна составлять от 0 до 128",
|
||||
"refresh_btn": "Обновить",
|
||||
"refresh_statics": "Обновить статистику",
|
||||
"release_notes": "Список изменений",
|
||||
"remove": "Удалить",
|
||||
"remove_tag": "Удалить %value%",
|
||||
"report_an_issue": "Сообщить о проблеме",
|
||||
"request_table_header": "Запрос",
|
||||
"requests_table_header": "Запросы",
|
||||
"reset": "Сбросить",
|
||||
"reset_settings": "Сбросить настройки",
|
||||
"reset_settings_confirm": "Да, сбросить",
|
||||
"response_details": "Детали ответа",
|
||||
"response_time": "Время ответа",
|
||||
"result": "Результат",
|
||||
"resume_protection_timer": "Защита возобновится через %time%",
|
||||
"rewrite_add": "Добавить правило перезаписи DNS-запросов",
|
||||
"rewrite_edit": "Редактировать правило перезаписи DNS-запросов",
|
||||
"rewrite_hosts_applied": "Переписано по правилу файла hosts",
|
||||
"rewrite_settings_updated": "Настройки перезаписи DNS-запросов успешно обновлены",
|
||||
"rewrite_updated": "Правило перезаписи DNS-запросов успешно обновлено",
|
||||
"rewritten": "Перезаписан",
|
||||
"rules_label": "Правила",
|
||||
"safe_browsing": "Безопасный интернет",
|
||||
"safe_search": "Безопасный поиск",
|
||||
"saturday": "Суббота",
|
||||
"save": "Сохранить",
|
||||
"save_btn": "Сохранить",
|
||||
"search_placeholder": "Поиск",
|
||||
"server_config_blocking_mode": "Режим блокировки",
|
||||
"server_config_blocking_mode_ipv4": "Блокировка IPv4",
|
||||
"server_config_blocking_mode_ipv6": "Блокировка IPv6",
|
||||
"server_config_custom_ip": "Пользовательский IP-адрес",
|
||||
"server_config_default": "Стандартный",
|
||||
"server_config_disable_ipv6": "Отключить обработку IPv6-адресов",
|
||||
"server_config_dnssec_enable": "Включить DNSSEC",
|
||||
"server_config_edns_enable": "Включить отправку EDNS Client Subnet",
|
||||
"server_config_nxdomain": "NXDOMAIN",
|
||||
"server_config_rate_limit_placeholder": "Введите rate limit",
|
||||
"server_config_rate_limit_whitelist_faq": "IP-адреса, на которые не распространяется ограничение скорости",
|
||||
"server_config_refused": "REFUSED",
|
||||
"server_config_subnet_len_ipv4": "Длина префикса подсети для IPv4-адресов",
|
||||
"server_config_subnet_len_ipv4_faq": "Длина префикса подсети для IPv4-адресов, используемых для ограничения скорости. По умолчанию 24",
|
||||
"server_config_subnet_len_ipv6": "Длина префикса подсети для IPv6-адресов",
|
||||
"server_config_subnet_len_ipv6_faq": "Длина префикса подсети для IPv6-адресов, используемых для ограничения скорости. По умолчанию 56",
|
||||
"set_static_ip": "Установить статический IP-адрес",
|
||||
"settings": "Настройки",
|
||||
"settings_browsing_security": "Антифишинг",
|
||||
"settings_confirm_clear_statistics": "Очистить статистику?",
|
||||
"settings_confirm_decrease_stats_rotation_interval": "Сократить время хранения статистики?",
|
||||
"settings_confirm_decrease_stats_rotation_interval_desc": "Это удалит статистику старше %value%",
|
||||
"settings_custom": "Другая",
|
||||
"settings_general_short": "General",
|
||||
"settings_global": "Глобальные",
|
||||
"settings_notify_changes_saved": "Изменения сохранены",
|
||||
"settings_notify_statistics_cleared": "Статистика очищена",
|
||||
"settings_parental_control": "Родительский контроль",
|
||||
"settings_rule_syntax": "Синтаксис",
|
||||
"settings_safe_search": "Безопасный поиск",
|
||||
"settings_statistics": "Статистика",
|
||||
"settings_statistics_clear": "Сбросить статистику",
|
||||
"settings_statistics_retention": "Сохранение статистики",
|
||||
"settings_yes_clear": "Да, очистить",
|
||||
"settings_yes_decrease": "Да, сократить",
|
||||
"setup_devices_dns_other_list_2": "<a>dnsproxy</a> поддерживает все известные зашифрованные DNS-протоколы.",
|
||||
"setup_devices_router_desc_1": "Настройте AdGuard Home на роутере вашей домашней сети и защитите все подключённые к нему устройства. Отдельно настраивать устройства не нужно.",
|
||||
"setup_devices_router_title": "Роутер",
|
||||
"setup_devices_windows_list_4": "В списке выберите <i>Internet Protocol Version 4 (TCP/IPv4)</i> для IPv4 или <i>Internet Protocol Version 6 (TCP/IPv6)</i> для IPv6.",
|
||||
"setup_dns_privacy_ioc_mac": "Конфигурация для iOS и macOS",
|
||||
"setup_dns_privacy_other_title": "Другие решения",
|
||||
"setup_dns_quote_desc": "Для правильной работы DNS-серверу AdGuard Home нужен статический IP-адрес в вашей локальной сети. Иначе ваш роутер может назначить ему другой IP-адрес, что нарушит DNS-защиту",
|
||||
"setup_guide_auth_subtitle": "Это нужно, чтобы другие не смогли попасть на вашу панель администратора AdGuard Home",
|
||||
"setup_guide_auth_title": "Задайте пароль",
|
||||
"show_blocked_responses": "Заблокировано",
|
||||
"show_processed_responses": "Обработан",
|
||||
"source_label": "Источник",
|
||||
"source_tooltip": "Источник",
|
||||
"stats_adult": "Заблокированные «взрослые» сайты",
|
||||
"stats_query_domain": "Часто запрашиваемые домены",
|
||||
"status_table_header": "Статус",
|
||||
"subnet_error": "Адреса должны быть внутри одной подсети",
|
||||
"sunday": "Воскресенье",
|
||||
"system_host_files": "Системные hosts-файлы",
|
||||
"table_client": "Клиент",
|
||||
"tags_title": "Теги",
|
||||
"test_upstreams": "Тест upstream серверов",
|
||||
"threats_blocked": "Заблокировано угроз",
|
||||
"thursday": "Четверг",
|
||||
"time_table_header": "Время",
|
||||
"top_blocked_domains": "Часто блокируемые домены",
|
||||
"top_clients": "Частые клиенты",
|
||||
"top_upstreams": "Часто запрашиваемые upstream-серверы",
|
||||
"total": "Всего",
|
||||
"try_again": "Попробовать снова",
|
||||
"tuesday": "Вторник",
|
||||
"unavailable_dhcp": "DHCP недоступен",
|
||||
"unavailable_dhcp_desc": "AdGuard Home не может запустить DHCP-сервер на вашей ОС",
|
||||
"unblock": "Разблокировать",
|
||||
"update_filters_disable": "Отключить",
|
||||
"update_filters_interval_custom": "Другая",
|
||||
"update_filters_interval_daily": "Каждый день",
|
||||
"update_filters_interval_hourly": "Каждый час",
|
||||
"update_filters_title": "Обновлять фильтры автоматически",
|
||||
"updated_custom_filtering_toast": "Пользовательские правила успешно сохранены",
|
||||
"updated_upstream_dns_toast": "DNS-серверы успешно обновлены",
|
||||
"updates_checked": "Доступна новая версия AdGuard Home",
|
||||
"updates_version_equal": "Версия AdGuard Home актуальна",
|
||||
"upstream": "Upstream-сервер",
|
||||
"upstream_dns": "Upstream DNS-серверы",
|
||||
"upstream_dns_addresses": "Адреса сервера, по одному в строке",
|
||||
"upstream_dns_fastest_addr": "Самый быстрый IP-адрес",
|
||||
"upstream_dns_load_balancing": "Распределение нагрузки\n",
|
||||
"upstream_dns_parallel_requests": "Параллельные запросы",
|
||||
"upstream_dns_servers_title": "Upstream DNS-серверы",
|
||||
"upstream_examples_title": "Примеры",
|
||||
"upstream_private_ptr_resolvers_title": "Использовать приватные обратные DNS-резолверы",
|
||||
"upstreams": "Upstreams",
|
||||
"url_label": "URL",
|
||||
"use_saved_key": "Использовать сохранённый ранее ключ",
|
||||
"user_rules": "Пользовательские правила",
|
||||
"user_rules_add_to_allowlist": "Добавить в белый список",
|
||||
"user_rules_check_button": "Проверить",
|
||||
"user_rules_check_client_placeholder": "Введите идентификатор клиента",
|
||||
"user_rules_check_hostname_label": "Имя хоста или домена",
|
||||
"user_rules_disable_filter": "Отключить фильтр",
|
||||
"user_rules_disable_parental_control": "Отключить Родительский контроль",
|
||||
"user_rules_dns_record_type_placeholder": "Выберите тип DNS-записи",
|
||||
"user_rules_domain_blocked": "Домен заблокирован",
|
||||
"user_rules_edit_dns_rewrite": "Редактировать правило перезаписи DNS-запросов",
|
||||
"user_rules_remove_dns_rewrite": "Удалить правило перезаписи DNS-запросов",
|
||||
"user_rules_rewrite_rule_is_applied": "Применено правило перезаписи",
|
||||
"user_rules_rewritten_to": "<strong>Перезаписано на:</strong> %value%",
|
||||
"user_rules_source": "<strong>Источник:</strong> %value%",
|
||||
"user_rules_title": "Пользовательские правила фильтрации",
|
||||
"username_label": "Имя пользователя",
|
||||
"username_placeholder": "Введите имя пользователя",
|
||||
"validated_with_dnssec": "Подтверждено с помощью DNSSEC",
|
||||
"wednesday": "Среда",
|
||||
"whois": "WHOIS",
|
||||
"yes": "Да"
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
{
|
||||
"access_settings_title": "访问设置",
|
||||
"actions_label": "操作",
|
||||
"actions_table_header": "操作",
|
||||
"add": "添加",
|
||||
"add_allowlist": "添加白名单",
|
||||
"add_blocklist": "添加黑名单",
|
||||
"add_persistent_client": "添加为持久客户端",
|
||||
"add_to_allowlist": "添加到白名单",
|
||||
"ads_blocked": "广告已拦截",
|
||||
"ads_blocked_card": "广告已拦截",
|
||||
"adult_websites_blocked_tooltip": "被阻止的成人网站总数",
|
||||
"all_lists_up_to_date_toast": "所有列表都是最新的",
|
||||
"allowed": "白名单",
|
||||
"allowlist_add": "添加白名单",
|
||||
"allowlist_edit": "编辑白名单",
|
||||
"apply": "应用",
|
||||
"auto_clients_title": "客户端(运行时间)",
|
||||
"autofix_warning_result": "因此,默认情况下所有来自系统的 DNS 请求都将由 AdGuard Home 处理。",
|
||||
"autofix_warning_text": "若您单击「修复」,AdGuard Home 将会配置您的系统以使用 AdGuard Home 的 DNS 服务器。",
|
||||
"average_time_processing": "平均处理时间",
|
||||
"average_upstream_response_time": "上游服务器的平均响应时间",
|
||||
"back": "返回",
|
||||
"block": "拦截",
|
||||
"block_for_this_client_only": "仅对此客户端拦截",
|
||||
"blocked": "已拦截",
|
||||
"blocked_by_cname_or_ip": "按 CNAME 或 IP 拦截",
|
||||
"blocked_service": "已阻止的服务",
|
||||
"blocked_services": "已阻止的服务",
|
||||
"blocked_threats": "已拦截的威胁",
|
||||
"blocked_threats_chart": "已拦截的威胁",
|
||||
"blocklist_add_manual": "手动添加",
|
||||
"blocklist_edit": "编辑黑名单",
|
||||
"blocklist_homepage": "主页",
|
||||
"blocklist_url_file_path": "URL 或文件路径",
|
||||
"blocklists_add": "添加黑名单",
|
||||
"bootstrap_dns": "Bootstrap DNS 服务器",
|
||||
"cache_cleared": "已成功清除 DNS 缓存",
|
||||
"cache_config_clear": "清除缓存",
|
||||
"cache_config_optimistic": "乐观缓存",
|
||||
"cache_config_ttl_validation": "最小缓存 TTL 值必须小于或等于最大值",
|
||||
"cache_enabled": "启用缓存",
|
||||
"cancel": "取消",
|
||||
"cancel_btn": "取消",
|
||||
"category_tooltip": "类别",
|
||||
"changes_saved_success": "更改已保存",
|
||||
"check_dhcp_servers": "检查 DHCP 服务器",
|
||||
"check_not_found": "未在您的筛选列表中找到",
|
||||
"check_updates_btn": "检查更新",
|
||||
"clear_btn": "清除",
|
||||
"client_add": "添加客户端",
|
||||
"client_details": "客户端详情",
|
||||
"client_id": "客户端 ID",
|
||||
"client_id_placeholder": "输入客户端 ID",
|
||||
"client_identifier": "标识符",
|
||||
"client_settings": "客户端设置",
|
||||
"client_table_header": "客户端",
|
||||
"clients_add_identifier": "添加标识符",
|
||||
"clients_dns_cache_size": "DNS 缓存大小,单位:字节",
|
||||
"clients_not_found": "未找到客户端",
|
||||
"clients_protection": "防护",
|
||||
"clients_tags": "标签",
|
||||
"clients_title": "持久客户端",
|
||||
"clients_use_global_settings": "使用全局设置",
|
||||
"close": "关闭",
|
||||
"confirm": "确认",
|
||||
"copied": "已复制到剪贴板",
|
||||
"copy": "复制",
|
||||
"custom_filter_rules": "自定义过滤器规则",
|
||||
"custom_filtering_rules": "自定义过滤器规则",
|
||||
"dark_theme": "暗黑主题",
|
||||
"dashboard": "仪表盘",
|
||||
"delete_btn": "删除",
|
||||
"delete_table_action": "删除",
|
||||
"dhcp_add_static_lease": "添加静态租约",
|
||||
"dhcp_config_saved": "已成功保存 DHCP 服务器配置",
|
||||
"dhcp_edit_static_lease": "编辑静态租约",
|
||||
"dhcp_form_gateway_input": "网关 IP",
|
||||
"dhcp_form_lease_title": "DHCP 租约时间(秒)",
|
||||
"dhcp_form_subnet_input": "子网掩码",
|
||||
"dhcp_hardware_address": "硬件地址",
|
||||
"dhcp_interface_select": "选择 DHCP 接口",
|
||||
"dhcp_ip_addresses": "IP 地址",
|
||||
"dhcp_leases_not_found": "未找到 DHCP 租约",
|
||||
"dhcp_new_static_lease": "新建静态租约",
|
||||
"dhcp_reset_leases": "重置所有租约",
|
||||
"dhcp_reset_leases_success": "成功重置了 DHCP 租约",
|
||||
"dhcp_settings": "DHCP 设置",
|
||||
"dhcp_static_leases_not_found": "未找到 DHCP 静态租约",
|
||||
"dhcp_table_hostname": "主机名",
|
||||
"dhcp_table_ip_address": "IP 地址",
|
||||
"dhcp_warning": "如果用户想要启用内置的 DHCP 服务器,请确保在当前网络中没有其它起作用的 DHCP 服务器。否则,此操作可能会中断已连接设备的网络连接!",
|
||||
"disable": "禁用",
|
||||
"disable_notify_until_tomorrow": "禁用保护直到明天",
|
||||
"disable_protection_btn": "禁用保护",
|
||||
"disabled_dhcp": "DHCP 服务器已禁用",
|
||||
"disabled_protection": "保护已禁用",
|
||||
"disallow_this_client": "不允许这个客户端",
|
||||
"dns_cache_config": "DNS 缓存配置",
|
||||
"dns_config": "DNS 服务配置",
|
||||
"dns_over_https": "DNS-over-HTTPS",
|
||||
"dns_over_quic": "DNS-over-QUIC",
|
||||
"dns_over_tls": "DNS-over-TLS",
|
||||
"dns_privacy": "DNS 隐私",
|
||||
"dns_query": "DNS 查询",
|
||||
"dns_rewrites": "DNS 重写",
|
||||
"dns_settings": "DNS 设置",
|
||||
"dns_test_ok_toast": "指定的 DNS 服务器现已正常运行",
|
||||
"domain": "域名",
|
||||
"domain_or_client": "域名或客户端",
|
||||
"down": "下移",
|
||||
"download_mobileconfig": "下载配置文件",
|
||||
"edit_table_action": "编辑",
|
||||
"enable": "启用",
|
||||
"enabled_dhcp": "DHCP 服务器已启用",
|
||||
"enabled_protection": "保护已启用",
|
||||
"enabled_table_header": "已启用",
|
||||
"encryption_certificate_path": "证书路径",
|
||||
"encryption_certificates": "证书",
|
||||
"encryption_certificates_input": "将您以 PEM 格式编码的证书复制粘贴到此处。",
|
||||
"encryption_certificates_source_content": "粘贴证书内容",
|
||||
"encryption_certificates_source_path": "设置证书路径",
|
||||
"encryption_chain_invalid": "证书链无效",
|
||||
"encryption_chain_valid": "证书链有效",
|
||||
"encryption_config_saved": "加密配置已保存",
|
||||
"encryption_doq": "DNS-over-QUIC 端口",
|
||||
"encryption_dot": "DNS-over-TLS 端口",
|
||||
"encryption_https": "HTTPS 端口",
|
||||
"encryption_key": "私钥",
|
||||
"encryption_key_input": "将您以 PEM 格式编码的证书私钥复制粘贴到此处。",
|
||||
"encryption_key_source_content": "粘贴私钥内容",
|
||||
"encryption_plain_dns": "无加密 DNS",
|
||||
"encryption_plain_dns_error": "要禁用无加密 DNS,请至少启用一个加密 DNS 协议",
|
||||
"encryption_private_key_path": "私钥路径",
|
||||
"encryption_server": "服务器名称",
|
||||
"encryption_server_enter": "输入您的域名",
|
||||
"encryption_title": "加密",
|
||||
"enter_cache_size": "输入缓存大小(字节)",
|
||||
"enter_ip_address_placeholder": "输入 IP 地址",
|
||||
"error": "错误",
|
||||
"fallback_dns": "后备 DNS 服务器",
|
||||
"filter_category_general": "General",
|
||||
"filter_category_general_desc": "在大多数设备上阻止跟踪和广告的列表",
|
||||
"filter_category_other": "其它",
|
||||
"filter_category_other_desc": "其他黑名单",
|
||||
"filter_category_regional": "区域",
|
||||
"filter_category_regional_desc": "专注于区域广告和跟踪服务器的列表",
|
||||
"filter_category_security": "安全",
|
||||
"filter_category_security_desc": "专用于拦截恶意软件、钓鱼或欺诈域名的列表",
|
||||
"filter_removed_successfully": "已成功删除该列表",
|
||||
"filters": "过滤器",
|
||||
"forgot_password": "忘记密码?",
|
||||
"form_enter_blocked_response_ttl": "输入拦截的 TTL 应答(秒)",
|
||||
"form_error_answer_format": "无效的响应格式",
|
||||
"form_error_client_id_format": "客户端 ID 必须只包含数字、小写字母和连字符",
|
||||
"form_error_domain_format": "无效的域格式",
|
||||
"form_error_equal": "不可相同",
|
||||
"form_error_gateway_ip": "租约期限不能有网关的 IP 地址",
|
||||
"form_error_ip4_format": "无效的 IPv4 地址",
|
||||
"form_error_ip6_format": "无效的 IPv6 地址",
|
||||
"form_error_ip_format": "无效 IP 地址",
|
||||
"form_error_mac_format": "无效的 MAC 地址",
|
||||
"form_error_password": "密码不匹配",
|
||||
"form_error_port": "输入有效的端口值",
|
||||
"form_error_port_range": "输入 80 - 65535 范围内的端口值",
|
||||
"form_error_port_unsafe": "这是一个不安全的端口",
|
||||
"form_error_required": "输入消息",
|
||||
"form_error_server_name": "无效的服务器名",
|
||||
"form_error_url_or_path_format": "无效的 URL 或列表的绝对路径",
|
||||
"friday": "星期五",
|
||||
"gateway_or_subnet_invalid": "子网掩码无效",
|
||||
"general_settings": "常规设置",
|
||||
"general_statistics": "概况统计",
|
||||
"greater_range_start_error": "必须大于范围起始值",
|
||||
"ignore_domains_desc_query": "匹配这些规则的查询不在查询日志",
|
||||
"ignore_domains_title": "被忽略的域名",
|
||||
"inactivity_schedule_add": "添加时间表",
|
||||
"inactivity_schedule_edit": "编辑时间表",
|
||||
"install_auth_confirm": "确认密码",
|
||||
"install_auth_password": "密码",
|
||||
"install_auth_password_enter": "输入密码",
|
||||
"install_auth_username": "用户名",
|
||||
"install_auth_username_enter": "输入用户名",
|
||||
"install_saved": "保存成功",
|
||||
"install_settings_all_interfaces": "所有接口",
|
||||
"install_settings_port": "端口",
|
||||
"install_static_error": "AdGuard Home 无法为这个网络接口自动配置它。请参阅如何手动完成此操作的说明。",
|
||||
"install_static_ok": "好消息!静态 IP 地址已经配置",
|
||||
"install_step": "步骤",
|
||||
"install_submit_title": "恭喜您!",
|
||||
"ip_address": "IP 地址",
|
||||
"known_tracker": "已知跟踪器",
|
||||
"light_theme": "浅色主题",
|
||||
"login": "登录",
|
||||
"logout": "退出",
|
||||
"make_static": "静态化",
|
||||
"milliseconds_abbreviation": "毫秒",
|
||||
"monday": "星期一",
|
||||
"name": "名称",
|
||||
"name_label": "名称",
|
||||
"name_table_header": "名称",
|
||||
"name_tooltip": "名称",
|
||||
"next": "下一步",
|
||||
"no": "否",
|
||||
"nothing_found": "没找到",
|
||||
"notify_undo": "撤销",
|
||||
"notify_updated": "已更新",
|
||||
"parental_control": "家长控制",
|
||||
"parental_group_cdn": "内容分发网络",
|
||||
"parental_group_development": "软件开发",
|
||||
"parental_group_gaming": "游戏",
|
||||
"parental_group_hosting": "网站托管",
|
||||
"parental_group_privacy": "隐私工具",
|
||||
"parental_group_shopping": "购物",
|
||||
"parental_group_social": "社交网络",
|
||||
"parental_group_social_network": "社交网络",
|
||||
"parental_group_software": "软件开发",
|
||||
"parental_group_streaming": "串流",
|
||||
"password_label": "密码",
|
||||
"password_placeholder": "输入密码",
|
||||
"plain_dns": "无加密 DNS",
|
||||
"privacy_policy": "隐私政策",
|
||||
"protection": "防护",
|
||||
"protocol": "协议",
|
||||
"query_log_allowed": "白名单",
|
||||
"query_log_blocked": "已拦截",
|
||||
"query_log_blocked_services": "已阻止的服务",
|
||||
"query_log_blocked_threats": "已拦截的威胁",
|
||||
"query_log_custom_filtering_rules": "自定义过滤器规则",
|
||||
"query_log_processed": "已处理",
|
||||
"query_log_retention": "查询日志保留时间",
|
||||
"query_log_rewritten": "重写项",
|
||||
"query_log_safe_search": "安全搜索",
|
||||
"query_log_strict_search": "使用双引号进行精确搜索",
|
||||
"rate_limit_subnet_len_ipv4_error": "IPv4 子网前缀长度应介于 0 到 32 之间",
|
||||
"rate_limit_subnet_len_ipv6_error": "IPv6 子网前缀长度应介于 0 到 128 之间",
|
||||
"refresh_btn": "刷新",
|
||||
"refresh_statics": "刷新统计数据",
|
||||
"release_notes": "更新日志",
|
||||
"remove": "移除",
|
||||
"report_an_issue": "问题反馈",
|
||||
"request_table_header": "请求",
|
||||
"requests_table_header": "请求",
|
||||
"reset": "重置",
|
||||
"reset_settings": "重置设置",
|
||||
"reset_settings_confirm": "重置",
|
||||
"response_details": "响应细节",
|
||||
"response_time": "响应时间",
|
||||
"result": "结果",
|
||||
"rewrite_add": "添加 DNS 重写",
|
||||
"rewrite_edit": "编辑 DNS 重写",
|
||||
"rewrite_hosts_applied": "根据 hosts 文件规则已被重写",
|
||||
"rewrite_settings_updated": "DNS 重写设置更新成功",
|
||||
"rewrite_updated": "DNS 重写已成功更新",
|
||||
"rewritten": "重写项",
|
||||
"rules_label": "规则",
|
||||
"safe_browsing": "安全浏览",
|
||||
"safe_search": "安全搜索",
|
||||
"saturday": "星期六",
|
||||
"save": "保存",
|
||||
"save_btn": "保存",
|
||||
"search_placeholder": "搜索",
|
||||
"server_config_blocking_mode": "拦截模式",
|
||||
"server_config_blocking_mode_ipv4": "拦截 IPv4",
|
||||
"server_config_blocking_mode_ipv6": "拦截 IPv6",
|
||||
"server_config_custom_ip": "自定义 IP 地址",
|
||||
"server_config_default": "默认",
|
||||
"server_config_disable_ipv6": "禁用 IPv6 地址的解析",
|
||||
"server_config_dnssec_enable": "启用 DNSSEC",
|
||||
"server_config_edns_enable": "启用 EDNS 客户端子网",
|
||||
"server_config_nxdomain": "NXDOMAIN",
|
||||
"server_config_rate_limit_placeholder": "输入限制速率",
|
||||
"server_config_rate_limit_whitelist_faq": "排除在速率限制之外的 IP 地址",
|
||||
"server_config_refused": "REFUSED",
|
||||
"server_config_subnet_len_ipv4": "IPv4 地址子网前缀长度",
|
||||
"server_config_subnet_len_ipv4_faq": "用于速率限制的 IPv4 地址子网前缀长度。默认为 24",
|
||||
"server_config_subnet_len_ipv6": "IPv6 地址子网前缀长度",
|
||||
"server_config_subnet_len_ipv6_faq": "用于速率限制的 IPv6 地址子网前缀长度。默认为 56",
|
||||
"set_static_ip": "设置一个静态 IP",
|
||||
"settings": "设置",
|
||||
"settings_browsing_security": "安全浏览",
|
||||
"settings_confirm_clear_statistics": "清除统计数据?",
|
||||
"settings_custom": "用户",
|
||||
"settings_general_short": "General",
|
||||
"settings_global": "全局",
|
||||
"settings_notify_changes_saved": "更改已保存",
|
||||
"settings_notify_statistics_cleared": "统计数据已清除",
|
||||
"settings_parental_control": "家长控制",
|
||||
"settings_rule_syntax": "规则语法",
|
||||
"settings_safe_search": "安全搜索",
|
||||
"settings_statistics": "统计",
|
||||
"settings_statistics_clear": "清除统计数据",
|
||||
"settings_statistics_retention": "统计保留",
|
||||
"settings_yes_clear": "是的,清除",
|
||||
"setup_devices_router_title": "路由器",
|
||||
"setup_dns_privacy_ioc_mac": "iOS 和 macOS 配置",
|
||||
"setup_dns_privacy_other_title": "其他实施方案",
|
||||
"show_blocked_responses": "已拦截",
|
||||
"show_processed_responses": "已处理",
|
||||
"source_label": "来源",
|
||||
"source_tooltip": "来源",
|
||||
"stats_adult": "被拦截的成人网站",
|
||||
"stats_query_domain": "请求域名排行",
|
||||
"status_table_header": "状态",
|
||||
"subnet_error": "地址必须在一个子网内",
|
||||
"sunday": "星期日",
|
||||
"system_host_files": "系统主机文件",
|
||||
"table_client": "客户端",
|
||||
"tags_title": "标签",
|
||||
"test_upstreams": "测试上游",
|
||||
"threats_blocked": "危险已拦截",
|
||||
"thursday": "星期四",
|
||||
"time_table_header": "时间",
|
||||
"top_blocked_domains": "被拦截域名排行",
|
||||
"top_clients": "客户端排行",
|
||||
"top_upstreams": "经常请求的上游服务器",
|
||||
"total": "合计",
|
||||
"try_again": "重试",
|
||||
"tuesday": "星期二",
|
||||
"unavailable_dhcp": "DHCP 无法使用",
|
||||
"unavailable_dhcp_desc": "AdGuard Home 无法在您的操作系统上运行 DHCP 服务器",
|
||||
"unblock": "取消拦截",
|
||||
"update_filters_disable": "禁用",
|
||||
"update_filters_interval_custom": "用户",
|
||||
"update_filters_interval_daily": "每天",
|
||||
"update_filters_interval_hourly": "每小时",
|
||||
"update_filters_title": "自动更新过滤器",
|
||||
"updated_custom_filtering_toast": "自定义规则保存成功",
|
||||
"updated_upstream_dns_toast": "上游服务器保存成功",
|
||||
"updates_checked": "AdGuard Home 的新版本现在可用",
|
||||
"updates_version_equal": "AdGuard Home已经是最新版本",
|
||||
"upstream": "上游服务器",
|
||||
"upstream_dns": "上游 DNS 服务器",
|
||||
"upstream_dns_addresses": "服务器地址(每行一个)",
|
||||
"upstream_dns_fastest_addr": "最快的 IP 地址",
|
||||
"upstream_dns_load_balancing": "负载均衡",
|
||||
"upstream_dns_parallel_requests": "并行请求",
|
||||
"upstream_dns_servers_title": "上游 DNS 服务器",
|
||||
"upstream_examples_title": "示例",
|
||||
"upstream_private_ptr_resolvers_title": "使用私人反向 DNS 解析器",
|
||||
"upstreams": "上游服务器",
|
||||
"url_label": "URL",
|
||||
"use_saved_key": "使用之前保存的密钥",
|
||||
"user_rules": "用户过滤器",
|
||||
"user_rules_add_to_allowlist": "添加到白名单",
|
||||
"user_rules_check_button": "检查",
|
||||
"user_rules_check_client_placeholder": "输入客户端标识符",
|
||||
"user_rules_check_hostname_label": "主机名或域名",
|
||||
"user_rules_disable_filter": "禁用过滤器",
|
||||
"user_rules_disable_parental_control": "停用家长控制",
|
||||
"user_rules_dns_record_type_placeholder": "选择 DNS 记录类型",
|
||||
"user_rules_domain_blocked": "域名已被拦截",
|
||||
"user_rules_edit_dns_rewrite": "编辑 DNS 重写",
|
||||
"user_rules_rewrite_rule_is_applied": " 重定向规则已应用",
|
||||
"user_rules_title": "自定义过滤器规则",
|
||||
"username_label": "用户名",
|
||||
"username_placeholder": "输入用户名",
|
||||
"validated_with_dnssec": "通过 DNSSEC 验证",
|
||||
"wednesday": "星期三",
|
||||
"whois": "WHOIS",
|
||||
"yes": "是"
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
{
|
||||
"access_settings_title": "存取設定",
|
||||
"actions_label": "動作",
|
||||
"actions_table_header": "動作",
|
||||
"add": "新增",
|
||||
"add_allowlist": "新增允許清單",
|
||||
"add_blocklist": "新增封鎖清單",
|
||||
"add_persistent_client": "新增為永久性客戶端",
|
||||
"add_to_allowlist": "加至允許清單",
|
||||
"ads_blocked": "已封鎖的廣告",
|
||||
"ads_blocked_card": "已封鎖的廣告",
|
||||
"adult_websites_blocked_tooltip": "已封鎖的成人網站之數量",
|
||||
"all_lists_up_to_date_toast": "所有的清單已是最新的",
|
||||
"allowed": "被允許的",
|
||||
"allowlist_add": "新增允許清單",
|
||||
"allowlist_edit": "編輯允許清單",
|
||||
"apply": "套用",
|
||||
"auto_clients_title": "執行時期用戶端",
|
||||
"autofix_warning_result": "因此,預設下,來自您的系統之所有的 DNS 請求將被 AdGuard Home 處理。",
|
||||
"autofix_warning_text": "如果您點擊\"修復\",AdGuard Home 將配置您的系統使用 AdGuard Home DNS 伺服器。",
|
||||
"average_time_processing": "平均的處理時間",
|
||||
"average_upstream_response_time": "平均的上游回應時間",
|
||||
"back": "返回",
|
||||
"block": "封鎖",
|
||||
"block_for_this_client_only": "僅對此用戶端封鎖",
|
||||
"blocked": "已封鎖的",
|
||||
"blocked_by_cname_or_ip": "被正規名稱(CNAME)或 IP 封鎖",
|
||||
"blocked_service": "已封鎖的服務",
|
||||
"blocked_services": "已封鎖的服務",
|
||||
"blocked_threats": "已封鎖的威脅",
|
||||
"blocked_threats_chart": "已封鎖的威脅",
|
||||
"blocklist_add_manual": "手動地新增",
|
||||
"blocklist_edit": "編輯封鎖清單",
|
||||
"blocklist_homepage": "首頁",
|
||||
"blocklist_url_file_path": "網址或檔案安裝路徑",
|
||||
"blocklists_add": "新增封鎖清單",
|
||||
"bootstrap_dns": "自我啟動(Bootstrap)DNS 伺服器",
|
||||
"cache_cleared": "DNS 快取被成功地清除",
|
||||
"cache_config_clear": "清除快取",
|
||||
"cache_config_optimistic": "樂觀快取",
|
||||
"cache_config_ttl_validation": "最小的快取存活時間(TTL)覆寫必須小於或等於最大的",
|
||||
"cache_enabled": "啟用快取",
|
||||
"cancel": "取消",
|
||||
"cancel_btn": "取消",
|
||||
"category_tooltip": "類別",
|
||||
"changes_saved_success": "更改已儲存",
|
||||
"check_dhcp_servers": "檢查動態主機設定協定(DHCP)伺服器",
|
||||
"check_not_found": "未在您的過濾器清單中被找到",
|
||||
"check_updates_btn": "檢查更新",
|
||||
"clear_btn": "清除",
|
||||
"client_add": "新增用戶端",
|
||||
"client_details": "用戶端細節",
|
||||
"client_id": "用戶端 ID",
|
||||
"client_id_placeholder": "輸入用戶端 ID",
|
||||
"client_identifier": "識別碼",
|
||||
"client_settings": "用戶端設定",
|
||||
"client_table_header": "用戶端",
|
||||
"clients_add_identifier": "新增識別碼",
|
||||
"clients_dns_cache_size": "DNS 快取大小,單位:位元",
|
||||
"clients_not_found": "無已發現之用戶端",
|
||||
"clients_protection": "防護",
|
||||
"clients_tags": "標記",
|
||||
"clients_title": "持續性用戶端",
|
||||
"clients_use_global_settings": "使用全域的設定",
|
||||
"close": "關閉",
|
||||
"confirm": "確認",
|
||||
"copied": "已複製到剪貼簿",
|
||||
"copy": "複製",
|
||||
"custom_filter_rules": "自訂的過濾規則",
|
||||
"custom_filtering_rules": "自訂的過濾規則",
|
||||
"dark_theme": "深色主題",
|
||||
"dashboard": "儀表板",
|
||||
"delete_btn": "刪除",
|
||||
"delete_table_action": "刪除",
|
||||
"dhcp_add_static_lease": "新增靜態租約",
|
||||
"dhcp_config_saved": "動態主機設定協定(DHCP)配置被成功地儲存",
|
||||
"dhcp_edit_static_lease": "編輯靜態租約",
|
||||
"dhcp_form_gateway_input": "閘道 IP",
|
||||
"dhcp_form_lease_title": "動態主機設定協定(DHCP)租約時間(以秒數)",
|
||||
"dhcp_form_subnet_input": "子網路遮罩",
|
||||
"dhcp_hardware_address": "硬體位址",
|
||||
"dhcp_interface_select": "選擇動態主機設定協定(DHCP)介面",
|
||||
"dhcp_ip_addresses": "IP 位址",
|
||||
"dhcp_leases_not_found": "無已發現之動態主機設定協定(DHCP)租約",
|
||||
"dhcp_new_static_lease": "新的靜態租約",
|
||||
"dhcp_reset_leases": "重置所有的租約",
|
||||
"dhcp_reset_leases_success": "動態主機設定協定(DHCP)租約被成功地重置",
|
||||
"dhcp_settings": "動態主機設定協定(DHCP)設定",
|
||||
"dhcp_static_leases_not_found": "無已發現之動態主機設定協定(DHCP)靜態租約",
|
||||
"dhcp_table_hostname": "主機名稱",
|
||||
"dhcp_table_ip_address": "IP 位址",
|
||||
"dhcp_warning": "如果您無論如何想要啟用動態主機設定協定(DHCP)伺服器,確保在您的網路中無其它現行的 DHCP 伺服器,因為對於該網路上的裝置,這可能破壞其網際網路連線!",
|
||||
"disable": "禁用",
|
||||
"disable_notify_until_tomorrow": "停用防護直到明天",
|
||||
"disable_protection_btn": "禁用防護",
|
||||
"disabled_dhcp": "DHCP 伺服器已停用",
|
||||
"disabled_protection": "已停用防護",
|
||||
"disallow_this_client": "不允許此用戶端",
|
||||
"dns_cache_config": "DNS 快取配置",
|
||||
"dns_config": "DNS 伺服器配置",
|
||||
"dns_over_https": "DNS-over-HTTPS",
|
||||
"dns_over_quic": "DNS-over-QUIC",
|
||||
"dns_over_tls": "DNS-over-TLS",
|
||||
"dns_privacy": "DNS 隱私",
|
||||
"dns_query": "DNS 查詢",
|
||||
"dns_rewrites": "DNS 改寫",
|
||||
"dns_settings": "DNS 設定",
|
||||
"dns_test_ok_toast": "已明確指定的 DNS 伺服器正在正確地運作",
|
||||
"domain": "網域",
|
||||
"domain_or_client": "網域或用戶端",
|
||||
"down": "停止運作的",
|
||||
"download_mobileconfig": "下載配置檔案",
|
||||
"edit_table_action": "編輯",
|
||||
"enable": "啟用",
|
||||
"enabled_dhcp": "動態主機設定協定(DHCP)伺服器被啟用",
|
||||
"enabled_protection": "已啟用防護",
|
||||
"enabled_table_header": "已啟用",
|
||||
"encryption_certificate_path": "憑證路徑",
|
||||
"encryption_certificates": "憑證",
|
||||
"encryption_certificates_input": "於此複製/貼上您的隱私增強郵件編碼之(PEM-encoded)憑證。",
|
||||
"encryption_certificates_source_content": "貼上該憑證內容",
|
||||
"encryption_certificates_source_path": "設定一個憑證檔案路徑",
|
||||
"encryption_chain_invalid": "憑證鏈結為無效的",
|
||||
"encryption_chain_valid": "憑證鏈結為有效的",
|
||||
"encryption_config_saved": "加密配置被儲存",
|
||||
"encryption_doq": "DNS-over-QUIC 連接埠",
|
||||
"encryption_dot": "DNS-over-TLS 連接埠",
|
||||
"encryption_https": "HTTPS 連接埠",
|
||||
"encryption_key": "私密金鑰",
|
||||
"encryption_key_input": "於此複製/貼上您的隱私增強郵件編碼之(PEM-encoded)私密金鑰供您的憑證。",
|
||||
"encryption_key_source_content": "貼上該私密金鑰內容",
|
||||
"encryption_plain_dns": "一般的 DNS",
|
||||
"encryption_plain_dns_error": "若要停用一般 DNS,請啟用至少一個加密 DNS 通訊協定",
|
||||
"encryption_private_key_path": "私密金鑰路徑",
|
||||
"encryption_server": "伺服器名稱",
|
||||
"encryption_server_enter": "輸入您的域名",
|
||||
"encryption_title": "加密",
|
||||
"enter_cache_size": "輸入快取大小(位元組)",
|
||||
"enter_ip_address_placeholder": "輸入 IP 位址",
|
||||
"error": "錯誤",
|
||||
"fallback_dns": "應變 DNS 伺服器",
|
||||
"filter_category_general": "General",
|
||||
"filter_category_general_desc": "封鎖大多數朝向裝置的追蹤和廣告之清單",
|
||||
"filter_category_other": "其它的",
|
||||
"filter_category_other_desc": "其它的封鎖清單",
|
||||
"filter_category_regional": "區域性的",
|
||||
"filter_category_regional_desc": "專注於區域性的廣告和追蹤伺服器之清單",
|
||||
"filter_category_security": "安全性",
|
||||
"filter_category_security_desc": "專門地旨在封鎖惡意、網路釣魚和詐騙的網域之清單",
|
||||
"filter_removed_successfully": "該清單已被成功地移除",
|
||||
"filters": "過濾器",
|
||||
"forgot_password": "忘了密碼嗎?",
|
||||
"form_enter_blocked_response_ttl": "請輸入已封鎖回應的存活時間(秒)",
|
||||
"form_error_answer_format": "無效的回應格式",
|
||||
"form_error_client_id_format": "用戶端 ID 必須只包含數字、小寫字母和連字號",
|
||||
"form_error_domain_format": "無效的網域格式",
|
||||
"form_error_equal": "必須為不相等的",
|
||||
"form_error_gateway_ip": "租約不能有閘道的 IP 位址",
|
||||
"form_error_ip4_format": "無效的 IPv4 位址",
|
||||
"form_error_ip6_format": "無效的 IPv6 位址",
|
||||
"form_error_ip_format": "無效的 IP 位址",
|
||||
"form_error_mac_format": "無效的媒體存取控制(MAC)位址",
|
||||
"form_error_password": "不相符的密碼",
|
||||
"form_error_port": "輸入有效的連接埠號碼",
|
||||
"form_error_port_range": "輸入在 80-65535 之範圍內的連接埠號碼",
|
||||
"form_error_port_unsafe": "不安全的連接埠",
|
||||
"form_error_required": "請填寫此欄位",
|
||||
"form_error_server_name": "無效的伺服器名稱",
|
||||
"form_error_url_or_path_format": "該清單之無效的網址或絕對的路徑",
|
||||
"friday": "星期五",
|
||||
"gateway_or_subnet_invalid": "無效的子網路遮罩",
|
||||
"general_settings": "一般設定",
|
||||
"general_statistics": "一般的統計資料",
|
||||
"greater_range_start_error": "必須大於起始範圍",
|
||||
"ignore_domains_desc_query": "符合這些規則的查詢不會被寫入查詢記錄中",
|
||||
"ignore_domains_title": "被忽略的網域",
|
||||
"inactivity_schedule_add": "新增排程",
|
||||
"inactivity_schedule_edit": "編輯排程",
|
||||
"install_auth_confirm": "確認密碼",
|
||||
"install_auth_password": "密碼",
|
||||
"install_auth_password_enter": "輸入密碼",
|
||||
"install_auth_username": "使用者名稱",
|
||||
"install_auth_username_enter": "輸入使用者名稱",
|
||||
"install_saved": "被成功地儲存",
|
||||
"install_settings_all_interfaces": "所有的介面",
|
||||
"install_settings_port": "連接埠",
|
||||
"install_static_error": "AdGuard Home 無法自動地配置它供此網路介面。請尋找有關如何手動地完成這個的用法說明。",
|
||||
"install_static_ok": "好消息!該靜態 IP 位址已被配置",
|
||||
"install_step": "步驟",
|
||||
"install_submit_title": "恭喜!",
|
||||
"ip_address": "IP 位址",
|
||||
"known_tracker": "已知的追蹤器",
|
||||
"light_theme": "淺色主題",
|
||||
"login": "登入",
|
||||
"logout": "登出",
|
||||
"make_static": "靜態化",
|
||||
"milliseconds_abbreviation": "ms",
|
||||
"monday": "星期一",
|
||||
"name": "名稱",
|
||||
"name_label": "名稱",
|
||||
"name_table_header": "名稱",
|
||||
"name_tooltip": "名稱",
|
||||
"next": "下一步",
|
||||
"no": "否",
|
||||
"nothing_found": "沒找到什麼",
|
||||
"notify_undo": "復原",
|
||||
"notify_updated": "已更新於",
|
||||
"parental_control": "家長控制",
|
||||
"parental_group_cdn": "內容傳遞網路(CDN)",
|
||||
"parental_group_development": "軟體開發",
|
||||
"parental_group_gaming": "遊戲",
|
||||
"parental_group_hosting": "Web 主機服務",
|
||||
"parental_group_privacy": "隱私工具",
|
||||
"parental_group_shopping": "購物",
|
||||
"parental_group_social": "社群網路",
|
||||
"parental_group_social_network": "社群網路",
|
||||
"parental_group_software": "軟體開發",
|
||||
"parental_group_streaming": "串流",
|
||||
"password_label": "密碼",
|
||||
"password_placeholder": "輸入密碼",
|
||||
"plain_dns": "一般的 DNS",
|
||||
"privacy_policy": "隱私政策",
|
||||
"protection": "防護",
|
||||
"protocol": "協定",
|
||||
"query_log_allowed": "被允許的",
|
||||
"query_log_blocked": "已封鎖的",
|
||||
"query_log_blocked_services": "已封鎖的服務",
|
||||
"query_log_blocked_threats": "已封鎖的威脅",
|
||||
"query_log_custom_filtering_rules": "自訂的過濾規則",
|
||||
"query_log_processed": "已處理的",
|
||||
"query_log_retention": "查詢記錄保留時間",
|
||||
"query_log_rewritten": "已改寫的",
|
||||
"query_log_safe_search": "安全搜尋",
|
||||
"query_log_strict_search": "使用雙引號於嚴謹的搜尋",
|
||||
"rate_limit_subnet_len_ipv4_error": "IPv4 子網路前綴長度應在 0 至 32 之間",
|
||||
"rate_limit_subnet_len_ipv6_error": "IPv6 子網路前綴長度應在 0 至 128 之間",
|
||||
"refresh_btn": "重新整理",
|
||||
"refresh_statics": "重新整理統計資料",
|
||||
"release_notes": "更新紀錄",
|
||||
"remove": "移除",
|
||||
"report_an_issue": "報告問題",
|
||||
"request_table_header": "請求",
|
||||
"requests_table_header": "請求",
|
||||
"reset": "重置",
|
||||
"reset_settings": "重置設定",
|
||||
"reset_settings_confirm": "重置",
|
||||
"response_details": "回應細節",
|
||||
"response_time": "回應時間",
|
||||
"result": "結果",
|
||||
"rewrite_add": "新增 DNS 改寫",
|
||||
"rewrite_edit": "編輯 DNS 重寫",
|
||||
"rewrite_hosts_applied": "被該主機檔案規則改寫",
|
||||
"rewrite_settings_updated": "DNS 重寫設定更新成功",
|
||||
"rewrite_updated": "DNS 重寫已成功更新",
|
||||
"rewritten": "已改寫的",
|
||||
"rules_label": "規則",
|
||||
"safe_browsing": "安全瀏覽",
|
||||
"safe_search": "安全搜尋",
|
||||
"saturday": "星期六",
|
||||
"save": "儲存",
|
||||
"save_btn": "儲存",
|
||||
"search_placeholder": "搜尋",
|
||||
"server_config_blocking_mode": "封鎖模式",
|
||||
"server_config_blocking_mode_ipv4": "封鎖 IPv4",
|
||||
"server_config_blocking_mode_ipv6": "封鎖 IPv6",
|
||||
"server_config_custom_ip": "自訂的 IP 位址",
|
||||
"server_config_default": "預設",
|
||||
"server_config_disable_ipv6": "停用 IPv6 位址解析",
|
||||
"server_config_dnssec_enable": "啟用網域名稱系統安全性擴充功能(DNSSEC)",
|
||||
"server_config_edns_enable": "啟用對於 DNS 的擴充機制(EDNS)用戶端子網路",
|
||||
"server_config_nxdomain": "不存在的網域(NXDOMAIN)",
|
||||
"server_config_rate_limit_placeholder": "輸入速率限制",
|
||||
"server_config_rate_limit_whitelist_faq": "從速率限制被排除的 IP 位址",
|
||||
"server_config_refused": "已拒絕(REFUSED)",
|
||||
"server_config_subnet_len_ipv4": "用於 IPv4 位址的子網路前綴長度",
|
||||
"server_config_subnet_len_ipv4_faq": "用於速率限制的 IPv4 位址的子網路前綴長度。預設值為 24",
|
||||
"server_config_subnet_len_ipv6": "用於 IPv6 位址的子網路前綴長度",
|
||||
"server_config_subnet_len_ipv6_faq": "用於速率限制的 IPv6 位址的子網路前綴長度。預設值為 56",
|
||||
"set_static_ip": "設定一組靜態 IP 位址",
|
||||
"settings": "設定",
|
||||
"settings_browsing_security": "瀏覽安全",
|
||||
"settings_confirm_clear_statistics": "清除統計資料嗎?",
|
||||
"settings_custom": "自訂的",
|
||||
"settings_general_short": "General",
|
||||
"settings_global": "全域的",
|
||||
"settings_notify_changes_saved": "更改已儲存",
|
||||
"settings_notify_statistics_cleared": "統計資料已清除",
|
||||
"settings_parental_control": "家長控制",
|
||||
"settings_rule_syntax": "規則語法",
|
||||
"settings_safe_search": "安全搜尋",
|
||||
"settings_statistics": "統計資料",
|
||||
"settings_statistics_clear": "清除統計資料",
|
||||
"settings_statistics_retention": "統計資料保留",
|
||||
"settings_yes_clear": "是的,清除",
|
||||
"setup_devices_router_title": "路由器",
|
||||
"setup_dns_privacy_ioc_mac": "iOS 和 macOS 配置",
|
||||
"setup_dns_privacy_other_title": "其它的執行",
|
||||
"show_blocked_responses": "已封鎖的",
|
||||
"show_processed_responses": "已處理的",
|
||||
"source_label": "來源",
|
||||
"source_tooltip": "來源",
|
||||
"stats_adult": "已封鎖的成人網站",
|
||||
"stats_query_domain": "熱門已查詢的網域",
|
||||
"status_table_header": "狀態",
|
||||
"subnet_error": "位址必須在子網路中",
|
||||
"sunday": "星期日",
|
||||
"system_host_files": "系統主機檔案",
|
||||
"table_client": "用戶端",
|
||||
"tags_title": "標記",
|
||||
"test_upstreams": "測試上行資料流",
|
||||
"threats_blocked": "已封鎖的威脅",
|
||||
"thursday": "星期四",
|
||||
"time_table_header": "時間",
|
||||
"top_blocked_domains": "熱門已封鎖的網域",
|
||||
"top_clients": "熱門用戶端",
|
||||
"top_upstreams": "熱門上游",
|
||||
"total": "總計",
|
||||
"try_again": "再次嘗試",
|
||||
"tuesday": "星期二",
|
||||
"unavailable_dhcp": "DHCP 為不可用的",
|
||||
"unavailable_dhcp_desc": "AdGuard Home 無法於您的作業系統上執行 DHCP 伺服器",
|
||||
"unblock": "解除封鎖",
|
||||
"update_filters_disable": "禁用",
|
||||
"update_filters_interval_custom": "自訂的",
|
||||
"update_filters_interval_daily": "每天",
|
||||
"update_filters_interval_hourly": "每小時",
|
||||
"update_filters_title": "自動地更新過濾器",
|
||||
"updated_custom_filtering_toast": "自訂的規則被成功地儲存",
|
||||
"updated_upstream_dns_toast": "上游的伺服器被成功地儲存",
|
||||
"updates_checked": "AdGuard Home 的新版本為可用的",
|
||||
"updates_version_equal": "AdGuard Home 為最新的",
|
||||
"upstream": "上游伺服器",
|
||||
"upstream_dns": "上游的 DNS 伺服器",
|
||||
"upstream_dns_addresses": "伺服器位址,每行一個",
|
||||
"upstream_dns_fastest_addr": "最快的 IP 位址",
|
||||
"upstream_dns_load_balancing": "負載平衡",
|
||||
"upstream_dns_parallel_requests": "並行的請求",
|
||||
"upstream_dns_servers_title": "上游的 DNS 伺服器",
|
||||
"upstream_examples_title": "範例",
|
||||
"upstream_private_ptr_resolvers_title": "使用私人反向的 DNS 解析器",
|
||||
"upstreams": "上游",
|
||||
"url_label": "網址",
|
||||
"use_saved_key": "使用該先前已儲存的金鑰",
|
||||
"user_rules": "使用者規則",
|
||||
"user_rules_add_to_allowlist": "加至允許清單",
|
||||
"user_rules_check_button": "檢查",
|
||||
"user_rules_check_client_placeholder": "輸入用戶識別碼",
|
||||
"user_rules_check_hostname_label": "主機名稱或域名",
|
||||
"user_rules_disable_filter": "禁用過濾器",
|
||||
"user_rules_disable_parental_control": "禁用家長控制",
|
||||
"user_rules_dns_record_type_placeholder": "選擇 DNS 記錄類型",
|
||||
"user_rules_domain_blocked": "網域被封鎖",
|
||||
"user_rules_edit_dns_rewrite": "編輯 DNS 重寫",
|
||||
"user_rules_rewrite_rule_is_applied": "改寫規則被套用",
|
||||
"user_rules_title": "自訂的過濾規則",
|
||||
"username_label": "使用者名稱",
|
||||
"username_placeholder": "輸入使用者名稱",
|
||||
"validated_with_dnssec": "已用網域名稱系統安全性擴充功能(DNSSEC)驗證",
|
||||
"wednesday": "星期三",
|
||||
"whois": "WHOIS",
|
||||
"yes": "是"
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getAccessList: vi.fn(),
|
||||
setAccessList: vi.fn(),
|
||||
addSuccessToast: vi.fn(),
|
||||
addErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('panel/api/Api', () => ({
|
||||
apiClient: {
|
||||
getAccessList: mocks.getAccessList,
|
||||
setAccessList: mocks.setAccessList,
|
||||
},
|
||||
}));
|
||||
vi.mock('panel/stores/toasts', () => ({
|
||||
addSuccessToast: mocks.addSuccessToast,
|
||||
addErrorToast: mocks.addErrorToast,
|
||||
}));
|
||||
|
||||
import { toggleClientBlock } from 'panel/stores/access';
|
||||
|
||||
describe('toggleClientBlock', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('not-disallowed + allowlist mode with >1 allowed → removes from allowed', async () => {
|
||||
mocks.getAccessList.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({
|
||||
allowed_clients: ['2.2.2.2'],
|
||||
disallowed_clients: [],
|
||||
blocked_hosts: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('not-disallowed, no allowlist → adds to disallowed', async () => {
|
||||
mocks.getAccessList.mockResolvedValue({
|
||||
allowed_clients: [],
|
||||
disallowed_clients: [],
|
||||
blocked_hosts: [],
|
||||
});
|
||||
await toggleClientBlock('3.3.3.3', false, '');
|
||||
expect(mocks.setAccessList).toHaveBeenCalledWith({
|
||||
allowed_clients: [],
|
||||
disallowed_clients: ['3.3.3.3'],
|
||||
blocked_hosts: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('disallowed + allowlist mode → adds to allowed', async () => {
|
||||
mocks.getAccessList.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({
|
||||
allowed_clients: ['1.1.1.1', '2.2.2.2'],
|
||||
disallowed_clients: ['2.2.2.2'],
|
||||
blocked_hosts: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('disallowed, no allowlist → removes from disallowed (uses rule)', async () => {
|
||||
mocks.getAccessList.mockResolvedValue({
|
||||
allowed_clients: [],
|
||||
disallowed_clients: ['client:X'],
|
||||
blocked_hosts: [],
|
||||
});
|
||||
await toggleClientBlock('1.2.3.4', true, 'client:X');
|
||||
expect(mocks.setAccessList).toHaveBeenCalledWith({
|
||||
allowed_clients: [],
|
||||
disallowed_clients: [],
|
||||
blocked_hosts: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
||||
import { render, screen } from '@solidjs/testing-library';
|
||||
|
||||
// jsdom has no matchMedia; App's theme effect and some hooks depend on it.
|
||||
beforeAll(() => {
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = (query: string) =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList;
|
||||
}
|
||||
});
|
||||
|
||||
// Keep the test focused on ROUTING: stub the store (no network/effects) and
|
||||
// the always-on chrome so the assertion only depends on route registration.
|
||||
vi.mock('panel/stores/dashboard', () => ({
|
||||
dashboardState: {
|
||||
processing: false,
|
||||
isCoreRunning: true,
|
||||
language: 'en',
|
||||
theme: undefined,
|
||||
protectionEnabled: false,
|
||||
},
|
||||
getDnsStatus: vi.fn(),
|
||||
getTimerStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('panel/common/ui/Header', () => ({
|
||||
Header: () => <div data-testid="chrome-header" />,
|
||||
}));
|
||||
vi.mock('panel/common/ui/Sidebar', () => ({
|
||||
Sidebar: () => <div data-testid="chrome-sidebar" />,
|
||||
}));
|
||||
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 }));
|
||||
|
||||
// Deterministic marker so the assertion does not depend on Dashboard data/i18n.
|
||||
vi.mock('panel/components/Dashboard', () => ({
|
||||
Dashboard: () => <div data-testid="route-dashboard" />,
|
||||
}));
|
||||
|
||||
import App from '../components/App';
|
||||
|
||||
describe('App routing', () => {
|
||||
it('registers the /dashboard route and renders matched route content', async () => {
|
||||
window.location.hash = '#/dashboard';
|
||||
|
||||
render(() => <App />);
|
||||
|
||||
expect(await screen.findByTestId('route-dashboard')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
|
||||
interface WebService {
|
||||
id: string;
|
||||
name: string;
|
||||
icon_svg: string;
|
||||
rules: string[];
|
||||
}
|
||||
|
||||
const filterServices = (
|
||||
services: WebService[],
|
||||
search: string,
|
||||
groupFilter: string[],
|
||||
serviceGroupMap: Map<string, string>,
|
||||
): WebService[] => {
|
||||
let filtered = services;
|
||||
|
||||
if (groupFilter.length > 0) {
|
||||
const selected = new Set(groupFilter);
|
||||
filtered = filtered.filter((s) => {
|
||||
const groupId = serviceGroupMap.get(s.id);
|
||||
return groupId && selected.has(groupId);
|
||||
});
|
||||
}
|
||||
|
||||
const term = search.trim().toLowerCase();
|
||||
if (term) {
|
||||
filtered = filtered.filter(
|
||||
(s) => s.name.toLowerCase().includes(term) || s.id.toLowerCase().includes(term),
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
describe('filterServices', () => {
|
||||
const services: WebService[] = [
|
||||
{ id: 'telegram', name: 'Telegram', icon_svg: '<svg/>', rules: [] },
|
||||
{ id: 'whatsapp', name: 'WhatsApp', icon_svg: '<svg/>', rules: [] },
|
||||
{ id: 'steam', name: 'Steam', icon_svg: '<svg/>', rules: [] },
|
||||
{ id: 'epic_games', name: 'Epic Games', icon_svg: '<svg/>', rules: [] },
|
||||
];
|
||||
|
||||
const serviceGroupMap = new Map([
|
||||
['telegram', 'messaging'],
|
||||
['whatsapp', 'messaging'],
|
||||
['steam', 'gaming'],
|
||||
['epic_games', 'gaming'],
|
||||
]);
|
||||
|
||||
test('no filters returns all services', () => {
|
||||
expect(filterServices(services, '', [], serviceGroupMap)).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('search by name (case-insensitive)', () => {
|
||||
const result = filterServices(services, 'tel', [], serviceGroupMap);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('telegram');
|
||||
});
|
||||
|
||||
test('search by id', () => {
|
||||
const result = filterServices(services, 'epic', [], serviceGroupMap);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('epic_games');
|
||||
});
|
||||
|
||||
test('filter by group', () => {
|
||||
const result = filterServices(services, '', ['gaming'], serviceGroupMap);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((s) => s.id)).toEqual(['steam', 'epic_games']);
|
||||
});
|
||||
|
||||
test('combined search + group filter', () => {
|
||||
const result = filterServices(services, 'steam', ['gaming'], serviceGroupMap);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('steam');
|
||||
});
|
||||
|
||||
test('no match returns empty array', () => {
|
||||
const result = filterServices(services, 'xyz', [], serviceGroupMap);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import intl from 'panel/common/intl';
|
||||
import { FILTERED_STATUS, SPECIAL_FILTER_ID } from 'panel/helpers/constants';
|
||||
|
||||
import { getCheckResultMeta } from '../components/UserRules/checkResultHelpers';
|
||||
|
||||
describe('getCheckResultMeta', () => {
|
||||
const filters = [
|
||||
{
|
||||
id: 101,
|
||||
name: 'Example Blocklist',
|
||||
url: 'https://filters.example/blocklist.txt',
|
||||
enabled: true,
|
||||
lastUpdated: '',
|
||||
rulesCount: 12,
|
||||
},
|
||||
];
|
||||
const whitelistFilters = [
|
||||
{
|
||||
id: 201,
|
||||
name: 'Example Allowlist',
|
||||
url: 'https://filters.example/allowlist.txt',
|
||||
enabled: true,
|
||||
lastUpdated: '',
|
||||
rulesCount: 7,
|
||||
},
|
||||
];
|
||||
|
||||
it('localizes filter and allowlist reasons through translation keys', () => {
|
||||
const blockedMeta = getCheckResultMeta({
|
||||
reason: FILTERED_STATUS.FILTERED_BLACK_LIST,
|
||||
rules: [{ filter_list_id: 101, text: '||filtered.example^' }],
|
||||
filters,
|
||||
whitelistFilters: [],
|
||||
});
|
||||
|
||||
const allowedMeta = getCheckResultMeta({
|
||||
reason: FILTERED_STATUS.NOT_FILTERED_WHITE_LIST,
|
||||
rules: [{ filter_list_id: 201, text: '@@||allowed.example^$important' }],
|
||||
filters: [],
|
||||
whitelistFilters,
|
||||
});
|
||||
|
||||
expect(blockedMeta.reason).toBeUndefined();
|
||||
expect(blockedMeta.source).toBe('Example Blocklist');
|
||||
expect(blockedMeta.sourceListType).toBe('blocklist');
|
||||
expect(allowedMeta.reason).toBeUndefined();
|
||||
expect(allowedMeta.source).toBe('Example Allowlist');
|
||||
expect(allowedMeta.sourceListType).toBe('allowlist');
|
||||
expect(allowedMeta.actions).toEqual([
|
||||
{
|
||||
kind: 'disable-filter',
|
||||
label: intl.getMessage('user_rules_disable_filter'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('localizes custom filtering and protection reasons through translation keys', () => {
|
||||
const customMeta = getCheckResultMeta({
|
||||
reason: FILTERED_STATUS.FILTERED_BLACK_LIST,
|
||||
rules: [
|
||||
{
|
||||
filter_list_id: SPECIAL_FILTER_ID.CUSTOM_FILTERING_RULES,
|
||||
text: '||blocked.example^',
|
||||
},
|
||||
],
|
||||
filters: [],
|
||||
whitelistFilters: [],
|
||||
});
|
||||
|
||||
const safeBrowsingMeta = getCheckResultMeta({
|
||||
reason: FILTERED_STATUS.FILTERED_SAFE_BROWSING,
|
||||
rules: [
|
||||
{ filter_list_id: SPECIAL_FILTER_ID.SAFE_BROWSING, text: 'adguard-malware-shavar' },
|
||||
],
|
||||
filters: [],
|
||||
whitelistFilters: [],
|
||||
});
|
||||
|
||||
const blockedServicesMeta = getCheckResultMeta({
|
||||
reason: FILTERED_STATUS.FILTERED_BLOCKED_SERVICE,
|
||||
rules: [{ filter_list_id: 0, text: '||amemv.com^' }],
|
||||
filters: [],
|
||||
whitelistFilters: [],
|
||||
});
|
||||
|
||||
expect(customMeta.reason).toBe(
|
||||
intl.getMessage('user_rules_reason_blocked_by', {
|
||||
source: intl.getMessage('custom_filtering_rules'),
|
||||
}),
|
||||
);
|
||||
expect(safeBrowsingMeta.reason).toBe(intl.getMessage('blocked_threats'));
|
||||
expect(safeBrowsingMeta.source).toBe(intl.getMessage('safe_browsing'));
|
||||
expect(safeBrowsingMeta.rule).toBe('adguard-malware-shavar');
|
||||
expect(blockedServicesMeta.reason).toBe(intl.getMessage('blocked_services'));
|
||||
expect(blockedServicesMeta.rule).toBe('||amemv.com^');
|
||||
});
|
||||
|
||||
it('renders Safe Search as a rewritten result with the Safe search status', () => {
|
||||
const safeSearchMeta = getCheckResultMeta({
|
||||
reason: FILTERED_STATUS.FILTERED_SAFE_SEARCH,
|
||||
rules: [],
|
||||
filters: [],
|
||||
whitelistFilters: [],
|
||||
});
|
||||
|
||||
expect(safeSearchMeta.tone).toBe('rewritten');
|
||||
expect(safeSearchMeta.title).toBe(intl.getMessage('user_rules_rewrite_rule_is_applied'));
|
||||
expect(safeSearchMeta.reason).toBe(intl.getMessage('settings_safe_search'));
|
||||
expect(safeSearchMeta.actions).toEqual([
|
||||
{
|
||||
kind: 'allow',
|
||||
label: intl.getMessage('user_rules_add_to_allowlist'),
|
||||
},
|
||||
{
|
||||
kind: 'disable-safesearch',
|
||||
label: intl.getMessage('user_rules_disable_safe_search'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits filter and allowlist reasons when the source name is unavailable', () => {
|
||||
const blockedMeta = getCheckResultMeta({
|
||||
reason: FILTERED_STATUS.FILTERED_BLACK_LIST,
|
||||
rules: [{ filter_list_id: 999, text: '||filtered.example^' }],
|
||||
filters: [],
|
||||
whitelistFilters: [],
|
||||
});
|
||||
|
||||
const allowedMeta = getCheckResultMeta({
|
||||
reason: FILTERED_STATUS.NOT_FILTERED_WHITE_LIST,
|
||||
rules: [{ filter_list_id: 999, text: '@@||allowed.example^$important' }],
|
||||
filters: [],
|
||||
whitelistFilters: [],
|
||||
});
|
||||
|
||||
expect(blockedMeta.reason).toBeUndefined();
|
||||
expect(allowedMeta.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps custom allowlist actions limited to block', () => {
|
||||
const customAllowedMeta = getCheckResultMeta({
|
||||
reason: FILTERED_STATUS.NOT_FILTERED_WHITE_LIST,
|
||||
rules: [
|
||||
{
|
||||
filter_list_id: SPECIAL_FILTER_ID.CUSTOM_FILTERING_RULES,
|
||||
text: '@@||allowed.example^$important',
|
||||
},
|
||||
],
|
||||
filters: [],
|
||||
whitelistFilters: [],
|
||||
});
|
||||
|
||||
expect(customAllowedMeta.actions).toEqual([
|
||||
{
|
||||
kind: 'block',
|
||||
label: intl.getMessage('block'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@solidjs/testing-library';
|
||||
|
||||
import { Identifiers } from 'panel/components/Clients/AddClient/blocks/Identifiers';
|
||||
import { initClientForm, clientFormState } from 'panel/stores/clientForm';
|
||||
|
||||
describe('Identifiers', () => {
|
||||
beforeEach(() => {
|
||||
initClientForm(null);
|
||||
});
|
||||
|
||||
it('keeps the entered identifier value (bug #1: value erased after typing)', () => {
|
||||
render(() => <Identifiers />);
|
||||
const input = document.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
|
||||
// Simulate typing — fireEvent.input triggers onInput, which calls handleChange.
|
||||
fireEvent.input(input, { target: { value: '1.2.3.4' } });
|
||||
|
||||
expect(input.value).toBe('1.2.3.4');
|
||||
expect(clientFormState.ids).toEqual(['1.2.3.4']);
|
||||
});
|
||||
|
||||
it('adds a new identifier row on button click (bug #2: add button did nothing)', () => {
|
||||
render(() => <Identifiers />);
|
||||
expect(document.querySelectorAll('input[type="text"]')).toHaveLength(1);
|
||||
|
||||
fireEvent.click(screen.getByTestId('client-form-add-identifier'));
|
||||
|
||||
const inputs = document.querySelectorAll('input[type="text"]');
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(clientFormState.ids).toEqual(['', '']);
|
||||
});
|
||||
|
||||
it('keeps existing values when adding a new row', () => {
|
||||
render(() => <Identifiers />);
|
||||
const first = document.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
fireEvent.input(first, { target: { value: '1.2.3.4' } });
|
||||
|
||||
fireEvent.click(screen.getByTestId('client-form-add-identifier'));
|
||||
|
||||
const inputs = document.querySelectorAll('input[type="text"]');
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect((inputs[0] as HTMLInputElement).value).toBe('1.2.3.4');
|
||||
expect((inputs[1] as HTMLInputElement).value).toBe('');
|
||||
expect(clientFormState.ids).toEqual(['1.2.3.4', '']);
|
||||
});
|
||||
|
||||
it('removes an identifier row (except the first one which has no remove button)', () => {
|
||||
render(() => <Identifiers />);
|
||||
fireEvent.click(screen.getByTestId('client-form-add-identifier'));
|
||||
expect(document.querySelectorAll('input[type="text"]')).toHaveLength(2);
|
||||
|
||||
// The remove button is the suffix of the second row.
|
||||
const removeBtn = document.querySelector('[aria-label]') as HTMLElement;
|
||||
fireEvent.click(removeBtn);
|
||||
|
||||
expect(document.querySelectorAll('input[type="text"]')).toHaveLength(1);
|
||||
expect(clientFormState.ids).toEqual(['']);
|
||||
});
|
||||
|
||||
it('shows a validation error on blur for an invalid identifier', async () => {
|
||||
render(() => <Identifiers />);
|
||||
const input = document.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
|
||||
fireEvent.input(input, { target: { value: 'not-a-valid-id' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
// The error message element is rendered by the Input when there is an error.
|
||||
expect(input.value).toBe('not-a-valid-id');
|
||||
});
|
||||
|
||||
it('clears the local error when the user types in the field', () => {
|
||||
render(() => <Identifiers />);
|
||||
const input = document.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
|
||||
// Trigger a validation error on blur.
|
||||
fireEvent.input(input, { target: { value: 'bad!!!' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
// Now type a valid value — fireEvent.input triggers onInput, which calls
|
||||
// handleChange → clears the local error.
|
||||
fireEvent.input(input, { target: { value: '1.2.3.4' } });
|
||||
|
||||
// The Input component only renders the error div when errorMessage is truthy.
|
||||
// Check that the wrapper does not have the error modifier class.
|
||||
const wrapper = input.closest('[class*="inputWrapper"]');
|
||||
expect(wrapper?.className).not.toContain('error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildClientBreadcrumbs } from 'panel/helpers/buildClientBreadcrumbs';
|
||||
import { RoutePath } from 'panel/components/Routes/Paths';
|
||||
|
||||
describe('buildClientBreadcrumbs', () => {
|
||||
it('builds add-mode breadcrumbs with extra links', () => {
|
||||
const result = buildClientBreadcrumbs({ mode: 'add', originalName: '' }, [
|
||||
{ path: RoutePath.ClientsBlockedServices, title: 'Blocked Services' },
|
||||
]);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].path).toBe(RoutePath.Clients);
|
||||
expect(result[1].path).toBe(RoutePath.ClientsAdd);
|
||||
expect(result[2].path).toBe(RoutePath.ClientsBlockedServices);
|
||||
});
|
||||
|
||||
it('builds edit-mode breadcrumbs with encoded clientName', () => {
|
||||
const result = buildClientBreadcrumbs({ mode: 'edit', originalName: 'My Client' }, []);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].path).toBe(RoutePath.Clients);
|
||||
expect(result[1].path).toBe(RoutePath.ClientsEdit);
|
||||
expect(result[1].props?.clientName).toBe('My%20Client');
|
||||
});
|
||||
|
||||
it('returns empty array when clientForm is null', () => {
|
||||
const result = buildClientBreadcrumbs(null, []);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildClientConfig } from 'panel/stores/clientForm';
|
||||
import { getInitialClientFormState } from 'panel/initialState';
|
||||
|
||||
describe('buildClientConfig', () => {
|
||||
it('syncs use_global_blocked_services from use_global_settings=true', () => {
|
||||
const form = {
|
||||
...getInitialClientFormState(),
|
||||
use_global_settings: true,
|
||||
use_global_blocked_services: false,
|
||||
};
|
||||
const config = buildClientConfig(form);
|
||||
expect(config.use_global_blocked_services).toBe(true);
|
||||
expect(config.use_global_settings).toBe(true);
|
||||
});
|
||||
|
||||
it('syncs use_global_blocked_services from use_global_settings=false', () => {
|
||||
const form = {
|
||||
...getInitialClientFormState(),
|
||||
use_global_settings: false,
|
||||
use_global_blocked_services: true,
|
||||
};
|
||||
const config = buildClientConfig(form);
|
||||
expect(config.use_global_blocked_services).toBe(false);
|
||||
expect(config.use_global_settings).toBe(false);
|
||||
});
|
||||
|
||||
it('filters empty IDs', () => {
|
||||
const form = {
|
||||
...getInitialClientFormState(),
|
||||
name: 'Test',
|
||||
ids: ['192.168.1.1', '', ' '],
|
||||
};
|
||||
const config = buildClientConfig(form);
|
||||
expect(config.ids).toEqual(['192.168.1.1']);
|
||||
});
|
||||
|
||||
it('splits upstreams by newline and filters blank lines', () => {
|
||||
const form = {
|
||||
...getInitialClientFormState(),
|
||||
upstreams: '1.1.1.1\n\n8.8.8.8\n \n',
|
||||
};
|
||||
const config = buildClientConfig(form);
|
||||
expect(config.upstreams).toEqual(['1.1.1.1', '8.8.8.8']);
|
||||
});
|
||||
|
||||
it('returns empty array when upstreams is empty string', () => {
|
||||
const form = {
|
||||
...getInitialClientFormState(),
|
||||
upstreams: '',
|
||||
};
|
||||
const config = buildClientConfig(form);
|
||||
expect(config.upstreams).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns all expected top-level keys', () => {
|
||||
const config = buildClientConfig(getInitialClientFormState());
|
||||
expect(Object.keys(config).sort()).toEqual([
|
||||
'blocked_services',
|
||||
'blocked_services_schedule',
|
||||
'filtering_enabled',
|
||||
'ids',
|
||||
'ignore_querylog',
|
||||
'ignore_statistics',
|
||||
'name',
|
||||
'parental_enabled',
|
||||
'safe_search',
|
||||
'safebrowsing_enabled',
|
||||
'tags',
|
||||
'upstreams',
|
||||
'upstreams_cache_enabled',
|
||||
'upstreams_cache_size',
|
||||
'use_global_blocked_services',
|
||||
'use_global_settings',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
// vi.hoisted ensures the mock is available before module evaluation.
|
||||
const dashboardMocks = vi.hoisted(() => ({
|
||||
getClients: vi.fn(),
|
||||
dashboardState: { clients: [] as any[] },
|
||||
}));
|
||||
|
||||
vi.mock('panel/stores/dashboard', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('panel/stores/dashboard')>('panel/stores/dashboard');
|
||||
return {
|
||||
...actual,
|
||||
getClients: dashboardMocks.getClients,
|
||||
get dashboardState() {
|
||||
return dashboardMocks.dashboardState;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
initClientForm,
|
||||
updateClientFormField,
|
||||
clearClientForm,
|
||||
clientFormState,
|
||||
saveClient,
|
||||
} from 'panel/stores/clientForm';
|
||||
|
||||
describe('clientForm store', () => {
|
||||
it('returns initial state by default', () => {
|
||||
clearClientForm();
|
||||
expect(clientFormState.mode).toBe('add');
|
||||
expect(clientFormState.name).toBe('');
|
||||
expect(clientFormState.ids).toEqual(['']);
|
||||
});
|
||||
|
||||
it('handles initClientForm with null payload (add mode)', () => {
|
||||
initClientForm(null);
|
||||
expect(clientFormState.mode).toBe('add');
|
||||
expect(clientFormState.name).toBe('');
|
||||
});
|
||||
|
||||
it('handles initClientForm with payload (edit mode)', () => {
|
||||
const payload = {
|
||||
name: 'My Laptop',
|
||||
ids: ['192.168.1.10'],
|
||||
filtering_enabled: true,
|
||||
};
|
||||
initClientForm(payload as any);
|
||||
expect(clientFormState.mode).toBe('edit');
|
||||
expect(clientFormState.name).toBe('My Laptop');
|
||||
expect(clientFormState.ids).toEqual(['192.168.1.10']);
|
||||
expect(clientFormState.filtering_enabled).toBe(true);
|
||||
expect(clientFormState.originalName).toBe('My Laptop');
|
||||
});
|
||||
|
||||
it('handles updateClientFormField', () => {
|
||||
clearClientForm();
|
||||
updateClientFormField({ field: 'name', value: 'Test' });
|
||||
expect(clientFormState.name).toBe('Test');
|
||||
});
|
||||
|
||||
it('handles nested field update via updateClientFormField', () => {
|
||||
clearClientForm();
|
||||
updateClientFormField({
|
||||
field: 'safe_search',
|
||||
value: {
|
||||
enabled: true,
|
||||
google: true,
|
||||
youtube: false,
|
||||
bing: false,
|
||||
duckduckgo: false,
|
||||
yandex: false,
|
||||
pixabay: false,
|
||||
},
|
||||
});
|
||||
expect(clientFormState.safe_search.enabled).toBe(true);
|
||||
expect(clientFormState.safe_search.google).toBe(true);
|
||||
});
|
||||
|
||||
it('handles clearClientForm', () => {
|
||||
initClientForm({ name: 'Test' } as any);
|
||||
expect(clientFormState.name).toBe('Test');
|
||||
clearClientForm();
|
||||
expect(clientFormState.name).toBe('');
|
||||
expect(clientFormState.mode).toBe('add');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveClient cross-client validation', () => {
|
||||
beforeEach(() => {
|
||||
clearClientForm();
|
||||
dashboardMocks.dashboardState.clients = [];
|
||||
});
|
||||
|
||||
it('rejects an identifier that already belongs to another client', async () => {
|
||||
// Set up the mocked dashboard store to include a client with a known ID.
|
||||
dashboardMocks.dashboardState.clients = [{ name: 'Other', ids: ['192.168.1.50'] } as any];
|
||||
|
||||
updateClientFormField({ field: 'name', value: 'New Client' });
|
||||
updateClientFormField({ field: 'ids', value: ['192.168.1.50'] });
|
||||
|
||||
const result = await saveClient();
|
||||
expect(result).toBe(false);
|
||||
expect(clientFormState.formErrors.ids).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects cache size of 0 when cache is enabled', async () => {
|
||||
clearClientForm();
|
||||
updateClientFormField({ field: 'name', value: 'Test' });
|
||||
updateClientFormField({ field: 'ids', value: ['192.168.1.1'] });
|
||||
updateClientFormField({ field: 'upstreams_cache_enabled', value: true });
|
||||
updateClientFormField({ field: 'upstreams_cache_size', value: 0 });
|
||||
|
||||
const result = await saveClient();
|
||||
expect(result).toBe(false);
|
||||
expect(clientFormState.formErrors.upstreams_cache_size).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
validateIdentifier,
|
||||
validateUpstreams,
|
||||
validateRequiredValue,
|
||||
validateIpv4,
|
||||
validatePort,
|
||||
validateInstallPort,
|
||||
validatePortTLS,
|
||||
validatePlainDns,
|
||||
validateIpForGatewaySubnetMask,
|
||||
validateIpv4InCidr,
|
||||
validatePasswordLength,
|
||||
validateIpNotDuplicate,
|
||||
validateIpPerLine,
|
||||
validateMaxValue,
|
||||
validateBetween,
|
||||
validateMinValue,
|
||||
validateCacheSize,
|
||||
} from 'panel/helpers/validators';
|
||||
|
||||
describe('validateIdentifier', () => {
|
||||
it('returns required error for empty string', () => {
|
||||
const result = validateIdentifier('', [], 0);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns required error for whitespace-only string', () => {
|
||||
const result = validateIdentifier(' ', [], 0);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns undefined for valid IPv4', () => {
|
||||
const result = validateIdentifier('192.168.1.1', ['192.168.1.1'], 0);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for valid IPv6', () => {
|
||||
const result = validateIdentifier('::1', ['::1'], 0);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for valid MAC', () => {
|
||||
const result = validateIdentifier('aa:bb:cc:dd:ee:ff', ['aa:bb:cc:dd:ee:ff'], 0);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for valid CIDR', () => {
|
||||
const result = validateIdentifier('192.168.1.0/24', ['192.168.1.0/24'], 0);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for valid ClientID', () => {
|
||||
const result = validateIdentifier('my-client-01', ['my-client-01'], 0);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns format error for invalid identifier', () => {
|
||||
const result = validateIdentifier('not a valid id!!!', [], 0);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns duplicate error for repeated identifier', () => {
|
||||
const result = validateIdentifier('test-id', ['other-id', 'test-id'], 0);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns undefined for unique identifiers', () => {
|
||||
const result = validateIdentifier('test-id', ['other-id', 'another-id'], 0);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateUpstreams', () => {
|
||||
it('returns undefined for an empty value', () => {
|
||||
const result = validateUpstreams('');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for comment lines', () => {
|
||||
const result = validateUpstreams('# this is a comment');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for blank lines', () => {
|
||||
const result = validateUpstreams('\n\n');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a plain IP address', () => {
|
||||
const result = validateUpstreams('1.1.1.1');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for an IP with port', () => {
|
||||
const result = validateUpstreams('1.1.1.1:53');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a protocol URL', () => {
|
||||
const result = validateUpstreams('tls://dns.example.com');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a hostname', () => {
|
||||
const result = validateUpstreams('dns.example.com');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns error for a line without dot or colon', () => {
|
||||
const result = validateUpstreams('not-a-valid-upstream');
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns error on the correct line number for mixed content', () => {
|
||||
const result = validateUpstreams('1.1.1.1\nbadline\ntls://ok.com');
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('skips comments and only flags real lines', () => {
|
||||
const result = validateUpstreams('# comment\nbadline\n1.1.1.1');
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRequiredValue', () => {
|
||||
it('returns undefined for non-empty string', () => {
|
||||
expect(validateRequiredValue('hello')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns error for empty string', () => {
|
||||
expect(validateRequiredValue('')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns error for whitespace-only string', () => {
|
||||
expect(validateRequiredValue(' ')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns undefined for 0 (zero is valid)', () => {
|
||||
expect(validateRequiredValue(0)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns error for undefined', () => {
|
||||
expect(validateRequiredValue(undefined)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns undefined for truthy number', () => {
|
||||
expect(validateRequiredValue(42)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns error for false', () => {
|
||||
expect(validateRequiredValue(false)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateIpv4', () => {
|
||||
it('returns undefined for valid IPv4', () => {
|
||||
expect(validateIpv4('192.168.1.1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns error for invalid IPv4', () => {
|
||||
expect(validateIpv4('999.999.999.999')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns undefined for empty string (skip)', () => {
|
||||
expect(validateIpv4('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for undefined (skip)', () => {
|
||||
expect(validateIpv4(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePort', () => {
|
||||
it('returns undefined for port in web range', () => {
|
||||
expect(validatePort(8080)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for boundary 80', () => {
|
||||
expect(validatePort(80)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for boundary 65535', () => {
|
||||
expect(validatePort(65535)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns error for 0 (falls outside range 80-65535)', () => {
|
||||
expect(validatePort(0)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns error for port below 80', () => {
|
||||
expect(validatePort(79)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns error for port above 65535', () => {
|
||||
expect(validatePort(65536)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateInstallPort', () => {
|
||||
it('returns undefined for valid port', () => {
|
||||
expect(validateInstallPort(80)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for boundary 1', () => {
|
||||
expect(validateInstallPort(1)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for boundary 65535', () => {
|
||||
expect(validateInstallPort(65535)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns error for 0 (install requires real port)', () => {
|
||||
expect(validateInstallPort(0)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns error for port above 65535', () => {
|
||||
expect(validateInstallPort(65536)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePortTLS', () => {
|
||||
it('returns undefined for 0 (disabled)', () => {
|
||||
expect(validatePortTLS(0)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for valid TLS port', () => {
|
||||
expect(validatePortTLS(853)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for boundary 80', () => {
|
||||
expect(validatePortTLS(80)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns error for port below 80', () => {
|
||||
expect(validatePortTLS(79)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns error for port above 65535', () => {
|
||||
expect(validatePortTLS(65536)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePlainDns', () => {
|
||||
it('returns undefined when both encryption and plain DNS are on', () => {
|
||||
expect(validatePlainDns(true, { enabled: true })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when plain DNS is on, encryption off', () => {
|
||||
expect(validatePlainDns(true, { enabled: false })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when encryption is on, plain DNS off', () => {
|
||||
expect(validatePlainDns(false, { enabled: true })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns error when both are off', () => {
|
||||
expect(validatePlainDns(false, { enabled: false })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateIpForGatewaySubnetMask', () => {
|
||||
const validContext = {
|
||||
v4: { gateway_ip: '192.168.1.1', subnet_mask: '255.255.255.0' },
|
||||
};
|
||||
|
||||
it('returns undefined for IP in subnet', () => {
|
||||
expect(validateIpForGatewaySubnetMask('192.168.1.5', validContext)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns error for IP outside subnet', () => {
|
||||
expect(validateIpForGatewaySubnetMask('10.0.0.1', validContext)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns undefined when context is missing', () => {
|
||||
expect(validateIpForGatewaySubnetMask('192.168.1.5', {})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for empty value', () => {
|
||||
expect(validateIpForGatewaySubnetMask('', validContext)).toBeUndefined();
|
||||
});
|
||||
|
||||
// REGRESSION: guard must use nested allValues.v4.gateway_ip, not flat
|
||||
it('validates IP using nested context only (no flat keys)', () => {
|
||||
const nestedOnly = {
|
||||
v4: { gateway_ip: '192.168.1.1', subnet_mask: '255.255.255.0' },
|
||||
};
|
||||
// IP outside subnet should return error even without flat keys
|
||||
expect(validateIpForGatewaySubnetMask('10.0.0.1', nestedOnly)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateIpv4InCidr', () => {
|
||||
it('returns undefined for IP within CIDR', () => {
|
||||
expect(validateIpv4InCidr('192.168.1.5', { cidr: '192.168.1.0/24' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns error for IP outside CIDR', () => {
|
||||
expect(validateIpv4InCidr('10.0.0.1', { cidr: '192.168.1.0/24' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePasswordLength', () => {
|
||||
it('returns true for too-short password (< 8 UTF-8 bytes)', () => {
|
||||
expect(validatePasswordLength('short')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns undefined for valid password', () => {
|
||||
expect(validatePasswordLength('longenough')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for empty string (skipped)', () => {
|
||||
expect(validatePasswordLength('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for undefined (skipped)', () => {
|
||||
expect(validatePasswordLength(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns true for password exceeding 72 UTF-8 bytes', () => {
|
||||
expect(validatePasswordLength('a'.repeat(73))).toBe(true);
|
||||
});
|
||||
|
||||
it('counts UTF-8 bytes, not characters (multibyte é = 2 bytes)', () => {
|
||||
// 'é' repeated 3 times = 6 UTF-8 bytes < 8 → too short
|
||||
expect(validatePasswordLength('é'.repeat(3))).toBe(true);
|
||||
// 'é' repeated 4 times = 8 UTF-8 bytes → valid
|
||||
expect(validatePasswordLength('é'.repeat(4))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateIpNotDuplicate', () => {
|
||||
const leases = [{ ip: '192.168.1.10' }, { ip: '192.168.1.20' }];
|
||||
|
||||
it('returns undefined for unique IP', () => {
|
||||
expect(validateIpNotDuplicate(leases)('192.168.1.30')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns error for duplicate IP', () => {
|
||||
expect(validateIpNotDuplicate(leases)('192.168.1.10')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('ignores the edit IP (editing existing lease)', () => {
|
||||
expect(validateIpNotDuplicate(leases, '192.168.1.10')('192.168.1.10')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for empty leases', () => {
|
||||
expect(validateIpNotDuplicate([])('192.168.1.1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for empty value', () => {
|
||||
expect(validateIpNotDuplicate(leases)('')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateIpPerLine', () => {
|
||||
it('returns undefined for valid multi-line IPs', () => {
|
||||
expect(validateIpPerLine('192.168.1.1\n10.0.0.1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns error when a line is invalid', () => {
|
||||
expect(validateIpPerLine('192.168.1.1\nnot-an-ip')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns undefined for empty string', () => {
|
||||
expect(validateIpPerLine('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips blank lines', () => {
|
||||
expect(validateIpPerLine('\n\n')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips blank lines between valid IPs', () => {
|
||||
expect(validateIpPerLine('192.168.1.1\n\n10.0.0.1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('numeric range validators', () => {
|
||||
it('validateMaxValue returns error when value exceeds max', () => {
|
||||
expect(validateMaxValue(5, 4)).toBeTruthy();
|
||||
expect(validateMaxValue(4, 4)).toBeUndefined();
|
||||
expect(validateMaxValue(0, 4)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('validateBetween returns error when value is out of range', () => {
|
||||
expect(validateBetween(-1, 0, 32)).toBeTruthy();
|
||||
expect(validateBetween(33, 0, 32)).toBeTruthy();
|
||||
expect(validateBetween(0, 0, 32)).toBeUndefined();
|
||||
expect(validateBetween(32, 0, 32)).toBeUndefined();
|
||||
expect(validateBetween(16, 0, 32)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('validateMinValue returns error when value is below min', () => {
|
||||
expect(validateMinValue(0, 1)).toBeTruthy();
|
||||
expect(validateMinValue(1, 1)).toBeUndefined();
|
||||
expect(validateMinValue(10, 1)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCacheSize', () => {
|
||||
it('returns undefined when cache is disabled', () => {
|
||||
expect(validateCacheSize(0, false)).toBeUndefined();
|
||||
expect(validateCacheSize(999999999999, false)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns error for 0 when enabled', () => {
|
||||
const result = validateCacheSize(0, true);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns undefined for a valid size when enabled', () => {
|
||||
expect(validateCacheSize(1000, true)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns error for value exceeding UINT32_MAX', () => {
|
||||
const result = validateCacheSize(4294967296, true);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns undefined at UINT32_MAX boundary', () => {
|
||||
expect(validateCacheSize(4294967295, true)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { render, fireEvent } from '@solidjs/testing-library';
|
||||
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
||||
|
||||
import { Form } from 'panel/components/Encryption/Form';
|
||||
import type { EncryptionFormValues } from 'panel/components/Encryption/Form';
|
||||
import { ENCRYPTION_SOURCE } from 'panel/helpers/constants';
|
||||
|
||||
// jsdom has no matchMedia; hooks like useIsMobile depend on it.
|
||||
beforeAll(() => {
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = (query: string) =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList;
|
||||
}
|
||||
});
|
||||
|
||||
vi.mock('panel/stores/encryption', () => ({
|
||||
setTlsConfig: vi.fn(),
|
||||
validateTlsConfig: vi.fn(),
|
||||
resetValidationStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
const baseEncryption = {
|
||||
processing: false,
|
||||
processingConfig: false,
|
||||
processingValidate: false,
|
||||
enabled: false,
|
||||
serve_plain_dns: true,
|
||||
dns_names: null,
|
||||
force_https: false,
|
||||
issuer: '',
|
||||
key_type: '',
|
||||
not_after: '',
|
||||
not_before: '',
|
||||
port_dns_over_tls: 853,
|
||||
port_dns_over_quic: 853,
|
||||
port_https: 443,
|
||||
port_dnscrypt: 0,
|
||||
subject: '',
|
||||
valid_chain: false,
|
||||
valid_key: false,
|
||||
valid_cert: false,
|
||||
valid_pair: false,
|
||||
status_cert: '',
|
||||
status_key: '',
|
||||
certificate_chain: '',
|
||||
private_key: '',
|
||||
server_name: '',
|
||||
warning_validation: '',
|
||||
certificate_path: '',
|
||||
private_key_path: '',
|
||||
private_key_saved: false,
|
||||
allow_unencrypted_doh: false,
|
||||
} as any;
|
||||
|
||||
const enabledNoCert: EncryptionFormValues = {
|
||||
enabled: true,
|
||||
serve_plain_dns: true,
|
||||
server_name: 'dns.example.com',
|
||||
force_https: false,
|
||||
port_https: 443,
|
||||
port_dns_over_tls: 853,
|
||||
port_dns_over_quic: 853,
|
||||
certificate_chain: '',
|
||||
private_key: '',
|
||||
certificate_path: '',
|
||||
private_key_path: '',
|
||||
certificate_source: ENCRYPTION_SOURCE.CONTENT,
|
||||
key_source: ENCRYPTION_SOURCE.CONTENT,
|
||||
private_key_saved: false,
|
||||
};
|
||||
|
||||
describe('Encryption Form', () => {
|
||||
const getByTestId = (testId: string): HTMLElement => {
|
||||
// The Input/Textarea components pass `name` but not `data-testid`
|
||||
// to the underlying DOM element. Map test ids to name selectors.
|
||||
const el = document.querySelector(`[name="${testId}"]`) as HTMLElement;
|
||||
if (!el) throw new Error(`Element not found: [name="${testId}"]`);
|
||||
return el;
|
||||
};
|
||||
|
||||
it('does NOT fire the validate request on blur when the cert is empty', () => {
|
||||
const debounced = vi.fn();
|
||||
render(() => (
|
||||
<Form
|
||||
initialValues={enabledNoCert}
|
||||
encryption={baseEncryption}
|
||||
onSubmit={vi.fn()}
|
||||
debouncedConfigValidation={debounced}
|
||||
/>
|
||||
));
|
||||
|
||||
const serverInput = getByTestId('server_name');
|
||||
fireEvent.change(serverInput, { target: { value: 'dns.example.com' } });
|
||||
fireEvent.blur(serverInput);
|
||||
|
||||
expect(debounced).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fires the validate request on blur once cert + key are filled', () => {
|
||||
const debounced = vi.fn();
|
||||
render(() => (
|
||||
<Form
|
||||
initialValues={enabledNoCert}
|
||||
encryption={baseEncryption}
|
||||
onSubmit={vi.fn()}
|
||||
debouncedConfigValidation={debounced}
|
||||
/>
|
||||
));
|
||||
|
||||
// Fill both cert and key content textareas.
|
||||
const certInput = getByTestId('certificate_chain');
|
||||
fireEvent.change(certInput, {
|
||||
target: { value: '-----BEGIN CERTIFICATE-----\nabc' },
|
||||
});
|
||||
|
||||
const keyInput = getByTestId('private_key');
|
||||
fireEvent.change(keyInput, {
|
||||
target: { value: '-----BEGIN PRIVATE KEY-----\nabc' },
|
||||
});
|
||||
|
||||
// Blur on server_name (Input, which forwards onBlur) triggers
|
||||
// handleBlur which reads all form values including the now-filled
|
||||
// cert and key signals.
|
||||
const serverInput = getByTestId('server_name');
|
||||
fireEvent.blur(serverInput);
|
||||
|
||||
expect(debounced).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { validateEncryptionForm } from 'panel/components/Encryption/validate';
|
||||
import { ENCRYPTION_SOURCE } from 'panel/helpers/constants';
|
||||
|
||||
const valid = {
|
||||
enabled: true,
|
||||
serve_plain_dns: true,
|
||||
server_name: 'dns.example.com',
|
||||
force_https: false,
|
||||
port_https: 443,
|
||||
port_dns_over_tls: 853,
|
||||
port_dns_over_quic: 853,
|
||||
certificate_chain: '-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----',
|
||||
private_key: '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----',
|
||||
certificate_path: '',
|
||||
private_key_path: '',
|
||||
certificate_source: ENCRYPTION_SOURCE.CONTENT,
|
||||
key_source: ENCRYPTION_SOURCE.CONTENT,
|
||||
private_key_saved: false,
|
||||
};
|
||||
|
||||
describe('validateEncryptionForm', () => {
|
||||
it('returns no errors for a fully valid enabled form', () => {
|
||||
expect(validateEncryptionForm(valid)).toEqual({});
|
||||
});
|
||||
|
||||
it('requires certificate_chain when enabled and source is content', () => {
|
||||
const errs = validateEncryptionForm({ ...valid, certificate_chain: '' });
|
||||
expect(errs.certificate_chain).toBeTruthy();
|
||||
});
|
||||
|
||||
it('requires certificate_path when enabled and source is path', () => {
|
||||
const errs = validateEncryptionForm({
|
||||
...valid,
|
||||
certificate_source: ENCRYPTION_SOURCE.PATH,
|
||||
certificate_path: '',
|
||||
certificate_chain: '',
|
||||
});
|
||||
expect(errs.certificate_path).toBeTruthy();
|
||||
});
|
||||
|
||||
it('requires private_key unless private_key_saved is true', () => {
|
||||
const errs = validateEncryptionForm({ ...valid, private_key: '' });
|
||||
expect(errs.private_key).toBeTruthy();
|
||||
|
||||
const ok = validateEncryptionForm({
|
||||
...valid,
|
||||
private_key: '',
|
||||
private_key_saved: true,
|
||||
});
|
||||
expect(ok.private_key).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT require cert/key when encryption is disabled', () => {
|
||||
const errs = validateEncryptionForm({
|
||||
...valid,
|
||||
enabled: false,
|
||||
serve_plain_dns: true,
|
||||
certificate_chain: '',
|
||||
private_key: '',
|
||||
});
|
||||
expect(errs.certificate_chain).toBeUndefined();
|
||||
expect(errs.private_key).toBeUndefined();
|
||||
});
|
||||
|
||||
it('flags an out-of-range https port', () => {
|
||||
const errs = validateEncryptionForm({ ...valid, port_https: 99999 });
|
||||
expect(errs.port_https).toBeTruthy();
|
||||
});
|
||||
|
||||
it('flags an unsafe https port', () => {
|
||||
const errs = validateEncryptionForm({ ...valid, port_https: 22 });
|
||||
expect(errs.port_https).toBeTruthy();
|
||||
});
|
||||
|
||||
it('allows port 0 for DoT/DoQ (disabled)', () => {
|
||||
const errs = validateEncryptionForm({
|
||||
...valid,
|
||||
port_dns_over_tls: 0,
|
||||
port_dns_over_quic: 0,
|
||||
});
|
||||
expect(errs.port_dns_over_tls).toBeUndefined();
|
||||
expect(errs.port_dns_over_quic).toBeUndefined();
|
||||
});
|
||||
|
||||
it('flags equal https and DoT ports', () => {
|
||||
const errs = validateEncryptionForm({
|
||||
...valid,
|
||||
port_https: 853,
|
||||
port_dns_over_tls: 853,
|
||||
});
|
||||
expect(errs.port_https).toBeTruthy();
|
||||
expect(errs.port_dns_over_tls).toBeTruthy();
|
||||
});
|
||||
|
||||
it('flags empty-string ports as invalid (coerces to 0)', () => {
|
||||
// Before the store loads, ports may be empty strings.
|
||||
// Number('') || 0 → 0, which fails validatePort (0 < 80).
|
||||
const errs = validateEncryptionForm({
|
||||
...valid,
|
||||
port_https: '' as any,
|
||||
});
|
||||
expect(errs.port_https).toBeTruthy();
|
||||
});
|
||||
|
||||
it('requires plain DNS when encryption is disabled', () => {
|
||||
const errs = validateEncryptionForm({
|
||||
...valid,
|
||||
enabled: false,
|
||||
serve_plain_dns: false,
|
||||
certificate_chain: '',
|
||||
private_key: '',
|
||||
});
|
||||
expect(errs.serve_plain_dns).toBeTruthy();
|
||||
});
|
||||
|
||||
it('flags an invalid server name', () => {
|
||||
const errs = validateEncryptionForm({
|
||||
...valid,
|
||||
server_name: 'not valid!',
|
||||
});
|
||||
expect(errs.server_name).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { render, screen } from '@solidjs/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Mock copy-to-clipboard to avoid jsdom limitation (window.prompt not implemented)
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
default: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
import { CopiedText } from 'panel/common/ui/CopiedText/CopiedText';
|
||||
|
||||
describe('CopiedText', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders the copy button and text', () => {
|
||||
render(() => <CopiedText text="192.168.1.1:8080" />);
|
||||
expect(screen.getByText('192.168.1.1:8080')).toBeInTheDocument();
|
||||
const copyButtons = screen.getAllByRole('button');
|
||||
expect(copyButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('resets copied state after 2000ms (bug B1 fix verification)', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
render(() => <CopiedText text="test-value" />);
|
||||
|
||||
const copyButtons = screen.getAllByRole('button');
|
||||
await user.click(copyButtons[0]);
|
||||
|
||||
expect(screen.getByText('test-value')).toBeInTheDocument();
|
||||
|
||||
// Before the B1 fix, createSignal(() => {...}) stored the function
|
||||
// as the signal value and the timer never executed.
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
expect(screen.getByText('test-value')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onCopy callback when text is copied', async () => {
|
||||
const onCopy = vi.fn();
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
render(() => <CopiedText text="copy-me" onCopy={onCopy} />);
|
||||
|
||||
const copyButtons = screen.getAllByRole('button');
|
||||
await user.click(copyButtons[0]);
|
||||
|
||||
expect(onCopy).toHaveBeenCalledWith('copy-me');
|
||||
});
|
||||
|
||||
it('cleans up timer on unmount without errors', () => {
|
||||
const { unmount } = render(() => <CopiedText text="unmount-test" />);
|
||||
expect(() => unmount()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { render, screen } from '@solidjs/testing-library';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { HashRouter, Route } from '@solidjs/router';
|
||||
import type { Component } from 'solid-js';
|
||||
|
||||
import { EmptyState } from 'panel/components/Dashboard/blocks/EmptyState/EmptyState';
|
||||
|
||||
const renderWithRouter = (component: Component) => {
|
||||
return render(() => (
|
||||
<HashRouter>
|
||||
<Route path="/" component={component} />
|
||||
</HashRouter>
|
||||
));
|
||||
};
|
||||
|
||||
describe('Dashboard EmptyState', () => {
|
||||
it('renders no_stats_yet in default mode', () => {
|
||||
renderWithRouter(() => <EmptyState mode="default" />);
|
||||
expect(screen.getByTestId('dashboard-empty-state')).toBeInTheDocument();
|
||||
expect(screen.getByText(/No stats yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders period_notify in disabled mode with settings link', () => {
|
||||
renderWithRouter(() => <EmptyState mode="disabled" />);
|
||||
expect(screen.getByTestId('dashboard-empty-state')).toBeInTheDocument();
|
||||
// period_notify: "To change the displayed periods, edit Statistics retention in General settings"
|
||||
expect(screen.getByText(/Statistics retention/)).toBeInTheDocument();
|
||||
// The "General settings" text is rendered inside a Link
|
||||
// Note: period_notify uses — the link name is "General\u00a0settings"
|
||||
expect(screen.getByRole('link', { name: /General/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults to default mode when no mode prop provided', () => {
|
||||
renderWithRouter(() => <EmptyState />);
|
||||
expect(screen.getByText(/No stats yet/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getTlsStatus: vi.fn(),
|
||||
getGlobalVersion: 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/stores/toasts', () => ({
|
||||
addErrorToast: mocks.addErrorToast,
|
||||
addSuccessToast: mocks.addSuccessToast,
|
||||
addNoticeToast: mocks.addNoticeToast,
|
||||
}));
|
||||
|
||||
import { getDnsStatus } from 'panel/stores/dashboard';
|
||||
|
||||
describe('getDnsStatus', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('fetches TLS status when the core is running (FR-005)', async () => {
|
||||
mocks.getGlobalVersion.mockResolvedValue({
|
||||
disabled: true,
|
||||
new_version: 'x',
|
||||
});
|
||||
mocks.getProfile.mockResolvedValue({ name: 'n', theme: 't' });
|
||||
mocks.getTlsStatus.mockResolvedValue({ enabled: false });
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
running: true,
|
||||
version: 'v',
|
||||
dns_port: 53,
|
||||
dns_addresses: [],
|
||||
protection_enabled: true,
|
||||
http_port: 80,
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
await getDnsStatus();
|
||||
// allow microtasks for chained getVersion/getTlsStatus/getProfile
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(mocks.getTlsStatus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findActiveDhcp: vi.fn(),
|
||||
getDhcpInterfaces: vi.fn(),
|
||||
getDhcpStatus: vi.fn(),
|
||||
setDhcpConfig: 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/stores/toasts', () => ({
|
||||
addErrorToast: mocks.addErrorToast,
|
||||
addSuccessToast: mocks.addSuccessToast,
|
||||
}));
|
||||
|
||||
import { findActiveDhcp, getDhcpInterfaces, setDhcpConfig, toggleDhcp } from 'panel/stores/dhcp';
|
||||
|
||||
describe('findActiveDhcp', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('passes { interface } not a bare string', async () => {
|
||||
mocks.findActiveDhcp.mockResolvedValue({
|
||||
v4: { other_server: { found: 'yes' }, static_ip: { static: 'yes' } },
|
||||
v6: { other_server: {} },
|
||||
});
|
||||
await findActiveDhcp('eth0');
|
||||
expect(mocks.findActiveDhcp).toHaveBeenCalledWith({ interface: 'eth0' });
|
||||
});
|
||||
|
||||
it('shows dhcp_found error with retry action when another DHCP server detected', async () => {
|
||||
mocks.getDhcpInterfaces.mockResolvedValue({
|
||||
eth0: { ipv4_addresses: ['1.1.1.1'], ipv6_addresses: [] },
|
||||
});
|
||||
mocks.findActiveDhcp.mockResolvedValue({
|
||||
v4: {
|
||||
other_server: { found: 'yes' },
|
||||
static_ip: { static: 'yes', ip: 'x' },
|
||||
},
|
||||
v6: { other_server: {} },
|
||||
});
|
||||
await getDhcpInterfaces();
|
||||
await findActiveDhcp('eth0');
|
||||
expect(mocks.addErrorToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: expect.objectContaining({ text: expect.any(String) }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows dhcp_not_found success toast when clean', async () => {
|
||||
mocks.getDhcpInterfaces.mockResolvedValue({
|
||||
eth0: { ipv4_addresses: ['1.1.1.1'], ipv6_addresses: [] },
|
||||
});
|
||||
mocks.findActiveDhcp.mockResolvedValue({
|
||||
v4: {
|
||||
other_server: { found: 'no' },
|
||||
static_ip: { static: 'yes', ip: '1.1.1.1' },
|
||||
},
|
||||
v6: { other_server: { found: 'no' } },
|
||||
});
|
||||
await getDhcpInterfaces();
|
||||
await findActiveDhcp('eth0');
|
||||
expect(mocks.addSuccessToast).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setDhcpConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.setDhcpConfig.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('shows dhcp_config_saved toast (FR-010)', async () => {
|
||||
await setDhcpConfig({
|
||||
v4: { range_start: 'a', range_end: 'b' },
|
||||
interface_name: 'eth0',
|
||||
});
|
||||
expect(mocks.addSuccessToast).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleDhcp', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.setDhcpConfig.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('computes enabled from passed config, not current state (FR-011)', async () => {
|
||||
await toggleDhcp({ enabled: false, interface_name: 'eth0' });
|
||||
expect(mocks.setDhcpConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { render, screen } from '@solidjs/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Dialog } from 'panel/common/ui/Dialog/Dialog';
|
||||
|
||||
describe('Dialog', () => {
|
||||
it('renders when visible', () => {
|
||||
render(() => (
|
||||
<Dialog visible onClose={() => {}} title="Test Dialog">
|
||||
<p>Body content</p>
|
||||
</Dialog>
|
||||
));
|
||||
expect(screen.getByText('Test Dialog')).toBeInTheDocument();
|
||||
expect(screen.getByText('Body content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when not visible', () => {
|
||||
render(() => (
|
||||
<Dialog visible={false} onClose={() => {}} title="Hidden">
|
||||
<p>Should not appear</p>
|
||||
</Dialog>
|
||||
));
|
||||
// Ark UI uses Presence — content is in DOM but hidden
|
||||
const title = screen.queryByText('Hidden');
|
||||
expect(title?.closest('[data-state="closed"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls onClose when close button clicked', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(() => <Dialog visible onClose={onClose} title="Closable" />);
|
||||
const closeButton = screen.getByRole('button');
|
||||
await userEvent.click(closeButton);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders without mask when mask=false', () => {
|
||||
render(() => <Dialog visible mask={false} onClose={() => {}} title="No Mask" />);
|
||||
expect(document.querySelector('[data-part="backdrop"]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders mask by default', () => {
|
||||
render(() => <Dialog visible onClose={() => {}} title="With Mask" />);
|
||||
expect(document.querySelector('[data-part="backdrop"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getDnsConfig: vi.fn(),
|
||||
addErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('panel/api/Api', () => ({
|
||||
apiClient: { getDnsConfig: mocks.getDnsConfig },
|
||||
}));
|
||||
vi.mock('panel/stores/toasts', () => ({ addErrorToast: mocks.addErrorToast }));
|
||||
|
||||
import { getDnsConfig, dnsConfigState } from 'panel/stores/dnsConfig';
|
||||
|
||||
describe('getDnsConfig', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('defaults null blocking IPs and empty upstream_mode (FR-012)', async () => {
|
||||
mocks.getDnsConfig.mockResolvedValue({
|
||||
blocking_ipv4: null,
|
||||
blocking_ipv6: null,
|
||||
upstream_mode: '',
|
||||
});
|
||||
await getDnsConfig();
|
||||
expect(dnsConfigState.blocking_ipv4).toBe('0.0.0.0');
|
||||
expect(dnsConfigState.blocking_ipv6).toBe('::');
|
||||
expect(dnsConfigState.upstream_mode).toBe('load_balance');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
setTlsConfig: vi.fn(),
|
||||
validateTlsConfig: vi.fn(),
|
||||
addErrorToast: vi.fn(),
|
||||
addSuccessToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('panel/api/Api', () => ({
|
||||
apiClient: {
|
||||
setTlsConfig: mocks.setTlsConfig,
|
||||
validateTlsConfig: mocks.validateTlsConfig,
|
||||
getGlobalStatus: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('panel/stores/toasts', () => ({
|
||||
addErrorToast: mocks.addErrorToast,
|
||||
addSuccessToast: mocks.addSuccessToast,
|
||||
}));
|
||||
vi.mock('panel/stores/dashboard', () => ({ getDnsStatus: vi.fn() }));
|
||||
|
||||
import {
|
||||
setTlsConfig,
|
||||
validateTlsConfig,
|
||||
resetValidationStatus,
|
||||
encryptionState,
|
||||
} from 'panel/stores/encryption';
|
||||
|
||||
describe('setTlsConfig', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('defaults empty ports to 0 (FR-013)', async () => {
|
||||
mocks.setTlsConfig.mockImplementation(async (v: any) => ({
|
||||
...v,
|
||||
certificate_chain: '',
|
||||
private_key: '',
|
||||
}));
|
||||
await setTlsConfig({
|
||||
certificate_chain: '',
|
||||
private_key: '',
|
||||
port_https: '',
|
||||
port_dns_over_tls: '',
|
||||
port_dns_over_quic: '',
|
||||
});
|
||||
const sent = mocks.setTlsConfig.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({
|
||||
valid_chain: true,
|
||||
valid_cert: true,
|
||||
valid_key: true,
|
||||
valid_pair: true,
|
||||
subject: 'CN=example.com',
|
||||
issuer: 'CN=example.com',
|
||||
key_type: 'RSA',
|
||||
not_after: '2027-01-01T00:00:00Z',
|
||||
not_before: '2026-01-01T00:00:00Z',
|
||||
dns_names: ['example.com'],
|
||||
warning_validation: 'self-signed certificate',
|
||||
certificate_chain: '',
|
||||
private_key: '',
|
||||
});
|
||||
|
||||
await validateTlsConfig({
|
||||
enabled: true,
|
||||
certificate_chain: 'x',
|
||||
private_key: 'y',
|
||||
});
|
||||
|
||||
expect(encryptionState.valid_cert).toBe(true);
|
||||
expect(encryptionState.warning_validation).toBe('self-signed certificate');
|
||||
|
||||
resetValidationStatus();
|
||||
|
||||
expect(encryptionState.valid_cert).toBe(false);
|
||||
expect(encryptionState.valid_chain).toBe(false);
|
||||
expect(encryptionState.valid_key).toBe(false);
|
||||
expect(encryptionState.valid_pair).toBe(false);
|
||||
expect(encryptionState.warning_validation).toBe('');
|
||||
expect(encryptionState.subject).toBe('');
|
||||
expect(encryptionState.issuer).toBe('');
|
||||
expect(encryptionState.key_type).toBe('');
|
||||
expect(encryptionState.dns_names).toBeNull();
|
||||
});
|
||||
|
||||
it('reloads when enabled+force_https on http: origin (FR-014)', async () => {
|
||||
const reloadFn = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { protocol: 'http:', reload: reloadFn },
|
||||
writable: true,
|
||||
});
|
||||
mocks.setTlsConfig.mockImplementation(async (v: any) => ({
|
||||
...v,
|
||||
certificate_chain: '',
|
||||
private_key: '',
|
||||
}));
|
||||
await setTlsConfig({
|
||||
certificate_chain: '',
|
||||
private_key: '',
|
||||
enabled: true,
|
||||
force_https: true,
|
||||
port_https: 443,
|
||||
});
|
||||
expect(reloadFn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
setRules,
|
||||
addFiltersBatch,
|
||||
getFilteringStatus,
|
||||
filteringState,
|
||||
} from 'panel/stores/filtering';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiSetRules: vi.fn(() => Promise.resolve(undefined)),
|
||||
apiAddFilter: vi.fn(() => Promise.resolve(undefined)),
|
||||
apiGetFilteringStatus: vi.fn(() => Promise.resolve({})),
|
||||
addSuccessToast: vi.fn(),
|
||||
addErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('panel/api/Api', () => ({
|
||||
apiClient: {
|
||||
setRules: mocks.apiSetRules,
|
||||
addFilter: mocks.apiAddFilter,
|
||||
getFilteringStatus: mocks.apiGetFilteringStatus,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('panel/stores/toasts', () => ({
|
||||
addSuccessToast: mocks.addSuccessToast,
|
||||
addErrorToast: mocks.addErrorToast,
|
||||
}));
|
||||
|
||||
describe('getFilteringStatus', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('normalizes filters to camelCase and joins user_rules array (FR-003)', async () => {
|
||||
mocks.apiGetFilteringStatus.mockResolvedValue({
|
||||
enabled: true,
|
||||
interval: 24,
|
||||
filters: [
|
||||
{
|
||||
id: 1,
|
||||
url: 'u',
|
||||
enabled: true,
|
||||
last_updated: 'x',
|
||||
name: 'n',
|
||||
rules_count: 9,
|
||||
},
|
||||
],
|
||||
whitelist_filters: [],
|
||||
user_rules: ['||a^', '||b^'],
|
||||
});
|
||||
|
||||
await getFilteringStatus();
|
||||
|
||||
expect(filteringState.userRules).toBe('||a^\n||b^');
|
||||
expect(filteringState.filters[0]).toEqual({
|
||||
id: 1,
|
||||
url: 'u',
|
||||
enabled: true,
|
||||
lastUpdated: 'x',
|
||||
name: 'n',
|
||||
rulesCount: 9,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRules', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sends user_rules as a string array (FR-021)', async () => {
|
||||
const result = await setRules('||fresh.example^\n');
|
||||
|
||||
expect(mocks.apiSetRules).toHaveBeenCalledWith({
|
||||
rules: ['||fresh.example^'],
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(filteringState.userRules).toBe('||fresh.example^\n');
|
||||
expect(filteringState.processingRules).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false and shows error toast on failure', async () => {
|
||||
mocks.apiSetRules.mockRejectedValueOnce(new Error('Network error'));
|
||||
const result = await setRules('||test.example^\n');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(filteringState.processingRules).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addFiltersBatch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('adds all filters successfully and shows aggregate toast', async () => {
|
||||
const filters = [
|
||||
{ url: 'https://example.com/filter1.txt', name: 'Filter One' },
|
||||
{ url: 'https://example.com/filter2.txt', name: 'Filter Two' },
|
||||
{ url: 'https://example.com/filter3.txt', name: 'Filter Three' },
|
||||
];
|
||||
|
||||
await addFiltersBatch(filters);
|
||||
|
||||
expect(mocks.apiAddFilter).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.addSuccessToast).toHaveBeenCalled();
|
||||
expect(filteringState.processingAddFilter).toBe(false);
|
||||
});
|
||||
|
||||
it('handles partial failures', async () => {
|
||||
mocks.apiAddFilter
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('Failed'));
|
||||
|
||||
const filters = [
|
||||
{ url: 'https://example.com/filter1.txt', name: 'Filter One' },
|
||||
{ url: 'https://example.com/filter2.txt', name: 'Filter Two' },
|
||||
];
|
||||
|
||||
await addFiltersBatch(filters);
|
||||
|
||||
expect(mocks.addSuccessToast).toHaveBeenCalled();
|
||||
expect(mocks.addErrorToast).toHaveBeenCalled();
|
||||
expect(filteringState.processingAddFilter).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
setRules,
|
||||
addFiltersBatch,
|
||||
getFilteringStatus,
|
||||
filteringState,
|
||||
} from 'panel/stores/filtering';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiSetRules: vi.fn(() => Promise.resolve(undefined)),
|
||||
apiAddFilter: vi.fn(() => Promise.resolve(undefined)),
|
||||
apiGetFilteringStatus: vi.fn(() => Promise.resolve({})),
|
||||
addSuccessToast: vi.fn(),
|
||||
addErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('panel/api/Api', () => ({
|
||||
apiClient: {
|
||||
setRules: mocks.apiSetRules,
|
||||
addFilter: mocks.apiAddFilter,
|
||||
getFilteringStatus: mocks.apiGetFilteringStatus,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('panel/stores/toasts', () => ({
|
||||
addSuccessToast: mocks.addSuccessToast,
|
||||
addErrorToast: mocks.addErrorToast,
|
||||
}));
|
||||
|
||||
describe('getFilteringStatus', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('normalizes filters to camelCase and joins user_rules array (FR-003)', async () => {
|
||||
mocks.apiGetFilteringStatus.mockResolvedValue({
|
||||
enabled: true,
|
||||
interval: 24,
|
||||
filters: [
|
||||
{
|
||||
id: 1,
|
||||
url: 'u',
|
||||
enabled: true,
|
||||
last_updated: 'x',
|
||||
name: 'n',
|
||||
rules_count: 9,
|
||||
},
|
||||
],
|
||||
whitelist_filters: [],
|
||||
user_rules: ['||a^', '||b^'],
|
||||
});
|
||||
|
||||
await getFilteringStatus();
|
||||
|
||||
expect(filteringState.userRules).toBe('||a^\n||b^');
|
||||
expect(filteringState.filters[0]).toEqual({
|
||||
id: 1,
|
||||
url: 'u',
|
||||
enabled: true,
|
||||
lastUpdated: 'x',
|
||||
name: 'n',
|
||||
rulesCount: 9,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRules', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sends user_rules as a string array (FR-021)', async () => {
|
||||
const result = await setRules('||fresh.example^\n');
|
||||
|
||||
expect(mocks.apiSetRules).toHaveBeenCalledWith({
|
||||
rules: ['||fresh.example^'],
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(filteringState.userRules).toBe('||fresh.example^\n');
|
||||
expect(filteringState.processingRules).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false and shows error toast on failure', async () => {
|
||||
mocks.apiSetRules.mockRejectedValueOnce(new Error('Network error'));
|
||||
const result = await setRules('||test.example^\n');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(filteringState.processingRules).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addFiltersBatch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('adds all filters successfully and shows aggregate toast', async () => {
|
||||
const filters = [
|
||||
{ url: 'https://example.com/filter1.txt', name: 'Filter One' },
|
||||
{ url: 'https://example.com/filter2.txt', name: 'Filter Two' },
|
||||
{ url: 'https://example.com/filter3.txt', name: 'Filter Three' },
|
||||
];
|
||||
|
||||
await addFiltersBatch(filters);
|
||||
|
||||
expect(mocks.apiAddFilter).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.addSuccessToast).toHaveBeenCalled();
|
||||
expect(filteringState.processingAddFilter).toBe(false);
|
||||
});
|
||||
|
||||
it('handles partial failures', async () => {
|
||||
mocks.apiAddFilter
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('Failed'));
|
||||
|
||||
const filters = [
|
||||
{ url: 'https://example.com/filter1.txt', name: 'Filter One' },
|
||||
{ url: 'https://example.com/filter2.txt', name: 'Filter Two' },
|
||||
];
|
||||
|
||||
await addFiltersBatch(filters);
|
||||
|
||||
expect(mocks.addSuccessToast).toHaveBeenCalled();
|
||||
expect(mocks.addErrorToast).toHaveBeenCalled();
|
||||
expect(filteringState.processingAddFilter).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,495 @@
|
||||
import { describe, expect, test, afterEach, vi, beforeEach, it } from 'vitest';
|
||||
|
||||
import {
|
||||
sortIp,
|
||||
countClientsStatistics,
|
||||
findAddressType,
|
||||
subnetMaskToBitMask,
|
||||
} from '../helpers/helpers';
|
||||
import { ADDRESS_TYPES } from '../helpers/constants';
|
||||
|
||||
describe('sortIp', () => {
|
||||
describe('ipv4', () => {
|
||||
test('one octet differ', () => {
|
||||
const arr = ['127.0.2.0', '127.0.3.0', '127.0.1.0'];
|
||||
const sortedArr = ['127.0.1.0', '127.0.2.0', '127.0.3.0'];
|
||||
|
||||
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
|
||||
});
|
||||
|
||||
test('few octets differ', () => {
|
||||
const arr = [
|
||||
'192.168.11.10',
|
||||
'192.168.10.0',
|
||||
'192.168.11.11',
|
||||
'192.168.10.10',
|
||||
'192.168.1.10',
|
||||
'192.168.0.1',
|
||||
'192.168.1.0',
|
||||
'192.168.1.1',
|
||||
'192.168.11.0',
|
||||
'192.168.0.10',
|
||||
'192.168.10.11',
|
||||
'192.168.0.11',
|
||||
'192.168.1.11',
|
||||
'192.168.0.0',
|
||||
'192.168.10.1',
|
||||
'192.168.11.1',
|
||||
];
|
||||
const sortedArr = [
|
||||
'192.168.0.0',
|
||||
'192.168.0.1',
|
||||
'192.168.0.10',
|
||||
'192.168.0.11',
|
||||
'192.168.1.0',
|
||||
'192.168.1.1',
|
||||
'192.168.1.10',
|
||||
'192.168.1.11',
|
||||
'192.168.10.0',
|
||||
'192.168.10.1',
|
||||
'192.168.10.10',
|
||||
'192.168.10.11',
|
||||
'192.168.11.0',
|
||||
'192.168.11.1',
|
||||
'192.168.11.10',
|
||||
'192.168.11.11',
|
||||
];
|
||||
|
||||
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
|
||||
|
||||
// Example from issue https://github.com/AdguardTeam/AdGuardHome/issues/1778#issuecomment-640937599
|
||||
const arr2 = [
|
||||
'192.168.2.11',
|
||||
'192.168.3.1',
|
||||
'192.168.2.100',
|
||||
'192.168.2.2',
|
||||
'192.168.2.1',
|
||||
'192.168.2.10',
|
||||
'192.168.2.99',
|
||||
'192.168.2.200',
|
||||
'192.168.2.199',
|
||||
];
|
||||
const sortedArr2 = [
|
||||
'192.168.2.1',
|
||||
'192.168.2.2',
|
||||
'192.168.2.10',
|
||||
'192.168.2.11',
|
||||
'192.168.2.99',
|
||||
'192.168.2.100',
|
||||
'192.168.2.199',
|
||||
'192.168.2.200',
|
||||
'192.168.3.1',
|
||||
];
|
||||
|
||||
expect(arr2.sort(sortIp)).toStrictEqual(sortedArr2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ipv6', () => {
|
||||
test('only long form', () => {
|
||||
const arr = [
|
||||
'2001:db8:11a3:9d7:0:0:0:2',
|
||||
'2001:db8:11a3:9d7:0:0:0:3',
|
||||
'2001:db8:11a3:9d7:0:0:0:1',
|
||||
];
|
||||
const sortedArr = [
|
||||
'2001:db8:11a3:9d7:0:0:0:1',
|
||||
'2001:db8:11a3:9d7:0:0:0:2',
|
||||
'2001:db8:11a3:9d7:0:0:0:3',
|
||||
];
|
||||
|
||||
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
|
||||
});
|
||||
|
||||
test('only short form', () => {
|
||||
const arr = ['2001:db8::', '2001:db7::', '2001:db9::'];
|
||||
const sortedArr = ['2001:db7::', '2001:db8::', '2001:db9::'];
|
||||
|
||||
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
|
||||
});
|
||||
|
||||
test('long and short forms', () => {
|
||||
const arr = [
|
||||
'2001:db8::',
|
||||
'2001:db7:11a3:9d7:0:0:0:2',
|
||||
'2001:db6:11a3:9d7:0:0:0:1',
|
||||
'2001:db6::',
|
||||
'2001:db7:11a3:9d7:0:0:0:1',
|
||||
'2001:db7::',
|
||||
];
|
||||
const sortedArr = [
|
||||
'2001:db6::',
|
||||
'2001:db6:11a3:9d7:0:0:0:1',
|
||||
'2001:db7::',
|
||||
'2001:db7:11a3:9d7:0:0:0:1',
|
||||
'2001:db7:11a3:9d7:0:0:0:2',
|
||||
'2001:db8::',
|
||||
];
|
||||
|
||||
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ipv4 and ipv6', () => {
|
||||
test('ipv6 long form', () => {
|
||||
const arr = [
|
||||
'127.0.0.3',
|
||||
'2001:db8:11a3:9d7:0:0:0:1',
|
||||
'2001:db8:11a3:9d7:0:0:0:3',
|
||||
'127.0.0.1',
|
||||
'2001:db8:11a3:9d7:0:0:0:2',
|
||||
'127.0.0.2',
|
||||
];
|
||||
const sortedArr = [
|
||||
'127.0.0.1',
|
||||
'127.0.0.2',
|
||||
'127.0.0.3',
|
||||
'2001:db8:11a3:9d7:0:0:0:1',
|
||||
'2001:db8:11a3:9d7:0:0:0:2',
|
||||
'2001:db8:11a3:9d7:0:0:0:3',
|
||||
];
|
||||
|
||||
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
|
||||
});
|
||||
|
||||
test('ipv6 short form', () => {
|
||||
const arr = [
|
||||
'2001:db8:11a3:9d7::1',
|
||||
'127.0.0.3',
|
||||
'2001:db8:11a3:9d7::3',
|
||||
'127.0.0.1',
|
||||
'2001:db8:11a3:9d7::2',
|
||||
'127.0.0.2',
|
||||
];
|
||||
const sortedArr = [
|
||||
'127.0.0.1',
|
||||
'127.0.0.2',
|
||||
'127.0.0.3',
|
||||
'2001:db8:11a3:9d7::1',
|
||||
'2001:db8:11a3:9d7::2',
|
||||
'2001:db8:11a3:9d7::3',
|
||||
];
|
||||
|
||||
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
|
||||
});
|
||||
|
||||
test('ipv6 long and short forms', () => {
|
||||
const arr = [
|
||||
'2001:db8:11a3:9d7::1',
|
||||
'127.0.0.3',
|
||||
'2001:db8:11a3:9d7:0:0:0:2',
|
||||
'127.0.0.1',
|
||||
'2001:db8:11a3:9d7::3',
|
||||
'127.0.0.2',
|
||||
];
|
||||
const sortedArr = [
|
||||
'127.0.0.1',
|
||||
'127.0.0.2',
|
||||
'127.0.0.3',
|
||||
'2001:db8:11a3:9d7::1',
|
||||
'2001:db8:11a3:9d7:0:0:0:2',
|
||||
'2001:db8:11a3:9d7::3',
|
||||
];
|
||||
|
||||
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
|
||||
});
|
||||
|
||||
test('always put ipv4 before ipv6', () => {
|
||||
const arr = [
|
||||
'::1',
|
||||
'0.0.0.2',
|
||||
'127.0.0.1',
|
||||
'::2',
|
||||
'2001:db8:11a3:9d7:0:0:0:2',
|
||||
'0.0.0.1',
|
||||
'2001:db8:11a3:9d7::1',
|
||||
];
|
||||
const sortedArr = [
|
||||
'0.0.0.1',
|
||||
'0.0.0.2',
|
||||
'127.0.0.1',
|
||||
'::1',
|
||||
'::2',
|
||||
'2001:db8:11a3:9d7::1',
|
||||
'2001:db8:11a3:9d7:0:0:0:2',
|
||||
];
|
||||
|
||||
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cidr', () => {
|
||||
test('only ipv4 cidr', () => {
|
||||
const arr = ['192.168.0.1/9', '192.168.0.1/7', '192.168.0.1/8'];
|
||||
const sortedArr = ['192.168.0.1/7', '192.168.0.1/8', '192.168.0.1/9'];
|
||||
|
||||
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
|
||||
});
|
||||
|
||||
test('ipv4 and cidr ipv4', () => {
|
||||
const arr = [
|
||||
'192.168.0.1/9',
|
||||
'192.168.0.1',
|
||||
'192.168.0.1/32',
|
||||
'192.168.0.1/7',
|
||||
'192.168.0.1/8',
|
||||
];
|
||||
const sortedArr = [
|
||||
'192.168.0.1/7',
|
||||
'192.168.0.1/8',
|
||||
'192.168.0.1/9',
|
||||
'192.168.0.1/32',
|
||||
'192.168.0.1',
|
||||
];
|
||||
|
||||
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
|
||||
});
|
||||
|
||||
test('only ipv6 cidr', () => {
|
||||
const arr = [
|
||||
'2001:db8:11a3:9d7::1/32',
|
||||
'2001:db8:11a3:9d7::1/64',
|
||||
'2001:db8:11a3:9d7::1/128',
|
||||
'2001:db8:11a3:9d7::1/24',
|
||||
];
|
||||
const sortedArr = [
|
||||
'2001:db8:11a3:9d7::1/24',
|
||||
'2001:db8:11a3:9d7::1/32',
|
||||
'2001:db8:11a3:9d7::1/64',
|
||||
'2001:db8:11a3:9d7::1/128',
|
||||
];
|
||||
|
||||
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
|
||||
});
|
||||
|
||||
test('ipv6 and cidr ipv6', () => {
|
||||
const arr = [
|
||||
'2001:db8:11a3:9d7::1/32',
|
||||
'2001:db8:11a3:9d7::1',
|
||||
'2001:db8:11a3:9d7::1/64',
|
||||
'2001:db8:11a3:9d7::1/128',
|
||||
'2001:db8:11a3:9d7::1/24',
|
||||
];
|
||||
const sortedArr = [
|
||||
'2001:db8:11a3:9d7::1/24',
|
||||
'2001:db8:11a3:9d7::1/32',
|
||||
'2001:db8:11a3:9d7::1/64',
|
||||
'2001:db8:11a3:9d7::1/128',
|
||||
'2001:db8:11a3:9d7::1',
|
||||
];
|
||||
|
||||
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid input', () => {
|
||||
const originalWarn = console.warn;
|
||||
|
||||
beforeEach(() => {
|
||||
console.warn = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
expect(console.warn).toHaveBeenCalled();
|
||||
console.warn = originalWarn;
|
||||
});
|
||||
|
||||
test('invalid strings', () => {
|
||||
const arr = ['invalid ip', 'invalid cidr'];
|
||||
|
||||
expect(arr.sort(sortIp)).toStrictEqual(arr);
|
||||
});
|
||||
|
||||
test('invalid ip', () => {
|
||||
const arr = ['127.0.0.2.', '.127.0.0.1.', '.2001:db8:11a3:9d7:0:0:0:0'];
|
||||
|
||||
expect(arr.sort(sortIp)).toStrictEqual(arr);
|
||||
});
|
||||
|
||||
test('invalid cidr', () => {
|
||||
const arr = ['127.0.0.2/33', '2001:db8:11a3:9d7:0:0:0:0/129'];
|
||||
|
||||
expect(arr.sort(sortIp)).toStrictEqual(arr);
|
||||
});
|
||||
|
||||
test('valid and invalid ip', () => {
|
||||
const arr = ['127.0.0.4.', '127.0.0.1', '.127.0.0.3', '127.0.0.2'];
|
||||
|
||||
expect(arr.sort(sortIp)).toStrictEqual(arr);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed', () => {
|
||||
test('ipv4, ipv6 in short and long forms and cidr', () => {
|
||||
const arr = [
|
||||
'2001:db8:11a3:9d7:0:0:0:1/32',
|
||||
'192.168.1.2',
|
||||
'127.0.0.2',
|
||||
'2001:db8:11a3:9d7::1/128',
|
||||
'2001:db8:11a3:9d7:0:0:0:1',
|
||||
'127.0.0.1/12',
|
||||
'192.168.1.1',
|
||||
'2001:db8::/32',
|
||||
'2001:db8:11a3:9d7::1/24',
|
||||
'192.168.1.2/12',
|
||||
'2001:db7::/32',
|
||||
'127.0.0.1',
|
||||
'2001:db8:11a3:9d7:0:0:0:2',
|
||||
'192.168.1.1/24',
|
||||
'2001:db7::/64',
|
||||
'2001:db7::',
|
||||
'2001:db8::',
|
||||
'2001:db8:11a3:9d7:0:0:0:1/128',
|
||||
'192.168.1.1/12',
|
||||
'127.0.0.1/32',
|
||||
'::1',
|
||||
];
|
||||
const sortedArr = [
|
||||
'127.0.0.1/12',
|
||||
'127.0.0.1/32',
|
||||
'127.0.0.1',
|
||||
'127.0.0.2',
|
||||
'192.168.1.1/12',
|
||||
'192.168.1.1/24',
|
||||
'192.168.1.1',
|
||||
'192.168.1.2/12',
|
||||
'192.168.1.2',
|
||||
'::1',
|
||||
'2001:db7::/32',
|
||||
'2001:db7::/64',
|
||||
'2001:db7::',
|
||||
'2001:db8::/32',
|
||||
'2001:db8::',
|
||||
'2001:db8:11a3:9d7::1/24',
|
||||
'2001:db8:11a3:9d7:0:0:0:1/32',
|
||||
'2001:db8:11a3:9d7::1/128',
|
||||
'2001:db8:11a3:9d7:0:0:0:1/128',
|
||||
'2001:db8:11a3:9d7:0:0:0:1',
|
||||
'2001:db8:11a3:9d7:0:0:0:2',
|
||||
];
|
||||
|
||||
expect(arr.sort(sortIp)).toStrictEqual(sortedArr);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAddressType', () => {
|
||||
it('should return IP type for IP addresses', () => {
|
||||
expect(findAddressType('127.0.0.1')).toStrictEqual(ADDRESS_TYPES.IP);
|
||||
});
|
||||
|
||||
it('should return CIDR type for CIDR addresses', () => {
|
||||
expect(findAddressType('127.0.0.1/8')).toStrictEqual(ADDRESS_TYPES.CIDR);
|
||||
});
|
||||
|
||||
it('should return UNKNOWN type for MAC addresses', () => {
|
||||
expect(findAddressType('00:1B:44:11:3A:B7')).toStrictEqual(ADDRESS_TYPES.UNKNOWN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countClientsStatistics', () => {
|
||||
test('single ip', () => {
|
||||
expect(
|
||||
countClientsStatistics(['127.0.0.1'], {
|
||||
'127.0.0.1': 1,
|
||||
}),
|
||||
).toStrictEqual(1);
|
||||
});
|
||||
|
||||
test('multiple ip', () => {
|
||||
expect(
|
||||
countClientsStatistics(['127.0.0.1', '127.0.0.2'], {
|
||||
'127.0.0.1': 1,
|
||||
'127.0.0.2': 2,
|
||||
}),
|
||||
).toStrictEqual(1 + 2);
|
||||
});
|
||||
|
||||
test('cidr', () => {
|
||||
expect(
|
||||
countClientsStatistics(['127.0.0.0/8'], {
|
||||
'127.0.0.1': 1,
|
||||
'127.0.0.2': 2,
|
||||
}),
|
||||
).toStrictEqual(1 + 2);
|
||||
});
|
||||
|
||||
test('cidr and multiple ip', () => {
|
||||
expect(
|
||||
countClientsStatistics(['1.1.1.1', '2.2.2.2', '3.3.3.0/24'], {
|
||||
'1.1.1.1': 1,
|
||||
'2.2.2.2': 2,
|
||||
'3.3.3.3': 3,
|
||||
}),
|
||||
).toStrictEqual(1 + 2 + 3);
|
||||
});
|
||||
|
||||
test('mac', () => {
|
||||
expect(
|
||||
countClientsStatistics(['00:1B:44:11:3A:B7', '2.2.2.2', '3.3.3.0/24'], {
|
||||
'1.1.1.1': 1,
|
||||
'2.2.2.2': 2,
|
||||
'3.3.3.3': 3,
|
||||
}),
|
||||
).toStrictEqual(2 + 3);
|
||||
});
|
||||
|
||||
test('not found', () => {
|
||||
expect(
|
||||
countClientsStatistics(['4.4.4.4', '5.5.5.5', '6.6.6.6'], {
|
||||
'1.1.1.1': 1,
|
||||
'2.2.2.2': 2,
|
||||
'3.3.3.3': 3,
|
||||
}),
|
||||
).toStrictEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subnetMaskToBitMask', () => {
|
||||
const subnetMasks = [
|
||||
'0.0.0.0',
|
||||
'128.0.0.0',
|
||||
'192.0.0.0',
|
||||
'224.0.0.0',
|
||||
'240.0.0.0',
|
||||
'248.0.0.0',
|
||||
'252.0.0.0',
|
||||
'254.0.0.0',
|
||||
'255.0.0.0',
|
||||
'255.128.0.0',
|
||||
'255.192.0.0',
|
||||
'255.224.0.0',
|
||||
'255.240.0.0',
|
||||
'255.248.0.0',
|
||||
'255.252.0.0',
|
||||
'255.254.0.0',
|
||||
'255.255.0.0',
|
||||
'255.255.128.0',
|
||||
'255.255.192.0',
|
||||
'255.255.224.0',
|
||||
'255.255.240.0',
|
||||
'255.255.248.0',
|
||||
'255.255.252.0',
|
||||
'255.255.254.0',
|
||||
'255.255.255.0',
|
||||
'255.255.255.128',
|
||||
'255.255.255.192',
|
||||
'255.255.255.224',
|
||||
'255.255.255.240',
|
||||
'255.255.255.248',
|
||||
'255.255.255.252',
|
||||
'255.255.255.254',
|
||||
'255.255.255.255',
|
||||
];
|
||||
|
||||
test('correct for all subnetMasks', () => {
|
||||
expect(
|
||||
subnetMasks
|
||||
.map((subnetMask) => {
|
||||
const bitmask = subnetMaskToBitMask(subnetMask);
|
||||
return subnetMasks[bitmask] === subnetMask;
|
||||
})
|
||||
.every((res) => res === true),
|
||||
).toEqual(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
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/stores/toasts', () => ({ addErrorToast: vi.fn() }));
|
||||
vi.mock('panel/helpers/constants', () => ({
|
||||
ALL_INTERFACES_IP: '0.0.0.0',
|
||||
INSTALL_FIRST_STEP: 1,
|
||||
STANDARD_DNS_PORT: 53,
|
||||
STANDARD_WEB_PORT: 80,
|
||||
}));
|
||||
|
||||
import { checkConfig, installState } from '../stores/install';
|
||||
|
||||
describe('checkConfig', () => {
|
||||
beforeEach(() => {
|
||||
mockCheckConfig.mockReset();
|
||||
});
|
||||
|
||||
it('persists the request web/dns ip and port to the store', async () => {
|
||||
// Simulate the API response — only status and can_autofix, no ip/port.
|
||||
mockCheckConfig.mockResolvedValue({
|
||||
web: { status: '', can_autofix: false },
|
||||
dns: { status: '', can_autofix: false },
|
||||
static_ip: { static: 'no', ip: '', error: '' },
|
||||
});
|
||||
|
||||
// User changed web port to 8080 and dns port to 5353.
|
||||
await checkConfig({
|
||||
web: { ip: '192.168.1.1', port: 8080 },
|
||||
dns: { ip: '192.168.1.1', port: 5353 },
|
||||
set_static_ip: false,
|
||||
});
|
||||
|
||||
expect(installState.web.ip).toBe('192.168.1.1');
|
||||
expect(installState.web.port).toBe(8080);
|
||||
expect(installState.dns.ip).toBe('192.168.1.1');
|
||||
expect(installState.dns.port).toBe(5353);
|
||||
});
|
||||
|
||||
it('does not lose ip/port when the API returns only status fields', async () => {
|
||||
mockCheckConfig.mockResolvedValue({
|
||||
web: { status: 'address already in use', can_autofix: true },
|
||||
dns: { status: '', can_autofix: false },
|
||||
static_ip: { static: 'no', ip: '', error: '' },
|
||||
});
|
||||
|
||||
await checkConfig({
|
||||
web: { ip: '0.0.0.0', port: 3000 },
|
||||
dns: { ip: '0.0.0.0', port: 53 },
|
||||
set_static_ip: false,
|
||||
});
|
||||
|
||||
// Port must still be the user's value, not the old store default.
|
||||
expect(installState.web.port).toBe(3000);
|
||||
expect(installState.web.status).toBe('address already in use');
|
||||
expect(installState.web.can_autofix).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { translate } from '@adguard/translate';
|
||||
import { createSolidDefaultValues, solidMessageConstructor } 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) => {
|
||||
const el = document.createElement(tag);
|
||||
if (props) {
|
||||
Object.entries(props).forEach(([key, value]) => {
|
||||
el.setAttribute(key, value);
|
||||
});
|
||||
}
|
||||
if (children !== undefined) {
|
||||
el.textContent = children;
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
// Minimal i18n mock for testing the translator layer
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mockI18n = (locale: string): any => ({
|
||||
getMessage: (key: string) => {
|
||||
const messages: Record<string, string> = {
|
||||
plain_only: 'Hello %name%',
|
||||
with_code: 'Use <code>%value%</code> for DNS queries',
|
||||
with_link: 'Learn more at <a>our docs</a>',
|
||||
with_mixed: '<code>%ipv4%</code>,<code>%ipv6%</code>: regular DNS',
|
||||
with_strong: 'This is <strong>important</strong>',
|
||||
with_b: 'This is <b>bold</b>',
|
||||
with_i: 'This is <i>italic</i>',
|
||||
with_p: '<p>Paragraph text</p>',
|
||||
};
|
||||
return messages[key] || '';
|
||||
},
|
||||
getUILanguage: () => locale,
|
||||
getBaseMessage: (key: string) => mockI18n('en').getMessage(key),
|
||||
getBaseUILanguage: () => 'en',
|
||||
});
|
||||
|
||||
describe('solidMessageConstructor', () => {
|
||||
it('joins arrays of only strings', () => {
|
||||
const result = solidMessageConstructor(['Hello ', 'world', '!']);
|
||||
expect(result).toBe('Hello world!');
|
||||
});
|
||||
|
||||
it('keeps arrays with non-string elements', () => {
|
||||
const el = h('code', null, 'test');
|
||||
const result = solidMessageConstructor(['Use ', el, ' for DNS']);
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect((result as unknown[])[1]).toBe(el);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSolidDefaultValues', () => {
|
||||
const getTranslator = () =>
|
||||
translate.createTranslator<any>(
|
||||
mockI18n('en'),
|
||||
solidMessageConstructor,
|
||||
createSolidDefaultValues(),
|
||||
);
|
||||
|
||||
it('returns plain string for translation without tags', () => {
|
||||
const t = getTranslator();
|
||||
const result = t.getMessage('plain_only', { name: 'World' });
|
||||
expect(result).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('renders <code> tag as SolidJS element (not HTML string)', () => {
|
||||
const t = getTranslator();
|
||||
const result = t.getMessage('with_code', { value: '1.2.3.4' });
|
||||
|
||||
// Should be an array because of the code element
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
const arr = result as unknown[];
|
||||
expect(arr).toHaveLength(3); // 'Use ', <code>, ' for DNS queries'
|
||||
|
||||
// The code element should be a real h() element, not a string
|
||||
expect(arr[1]).not.toBe('<code>1.2.3.4</code>');
|
||||
expect(typeof arr[1]).not.toBe('string');
|
||||
});
|
||||
|
||||
it('supports per-call tag overrides (e.g., <a> with JSX handler)', () => {
|
||||
const t = getTranslator();
|
||||
const result = t.getMessage('with_link', {
|
||||
a: (text: string) => h('a', { href: 'https://example.com' }, text),
|
||||
});
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
const arr = result as unknown[];
|
||||
// The <a> element should be the SolidJS element from our handler
|
||||
expect(typeof arr[2]).not.toBe('string');
|
||||
});
|
||||
|
||||
it('renders <strong> as SolidJS element', () => {
|
||||
const t = getTranslator();
|
||||
const result = t.getMessage('with_strong');
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('renders <code> fallback without per-call handler', () => {
|
||||
const t = getTranslator();
|
||||
// Only pass ipv4, ipv6 — no 'code' handler
|
||||
const result = t.getMessage('with_mixed', {
|
||||
ipv4: '94.140.14.140',
|
||||
ipv6: '2a10:50c0::1:ff',
|
||||
});
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
const arr = result as unknown[];
|
||||
// Both <code> elements should be SolidJS elements from defaults
|
||||
expect(typeof arr[0]).not.toBe('string'); // first code
|
||||
expect(typeof arr[2]).not.toBe('string'); // second code
|
||||
});
|
||||
|
||||
it('handles <b>, <i>, <p> tags via defaults', () => {
|
||||
const t = getTranslator();
|
||||
const boldResult = t.getMessage('with_b');
|
||||
expect(Array.isArray(boldResult)).toBe(true);
|
||||
|
||||
const italicResult = t.getMessage('with_i');
|
||||
expect(Array.isArray(italicResult)).toBe(true);
|
||||
|
||||
const pResult = t.getMessage('with_p');
|
||||
expect(Array.isArray(pResult)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('intl.getMessage — missing placeholder fallback', () => {
|
||||
it('returns the raw key and warns when a placeholder value is missing', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const t = translate.createTranslator<any>(
|
||||
mockI18n('en'),
|
||||
solidMessageConstructor,
|
||||
createSolidDefaultValues(),
|
||||
);
|
||||
|
||||
// Mirror the try-catch pattern from intl's getMessage
|
||||
const safeGetMessage = (key: string, values?: any) => {
|
||||
try {
|
||||
return t.getMessage(key, values);
|
||||
} catch (e) {
|
||||
console.warn('[i18n] Missing placeholder value for key:', key, e);
|
||||
return key;
|
||||
}
|
||||
};
|
||||
|
||||
// 'plain_only' has %name% but we pass no values → should return the key
|
||||
const result = safeGetMessage('plain_only');
|
||||
expect(result).toBe('plain_only');
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy.mock.calls[0][1]).toBe('plain_only');
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('still returns formatted string when all values are provided', () => {
|
||||
const t = translate.createTranslator<any>(
|
||||
mockI18n('en'),
|
||||
solidMessageConstructor,
|
||||
createSolidDefaultValues(),
|
||||
);
|
||||
|
||||
const safeGetMessage = (key: string, values?: any) => {
|
||||
try {
|
||||
return t.getMessage(key, values);
|
||||
} catch {
|
||||
return key;
|
||||
}
|
||||
};
|
||||
|
||||
const result = safeGetMessage('plain_only', { name: 'World' });
|
||||
expect(result).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('returns the raw key when getPlural is missing a required placeholder', () => {
|
||||
const mockPluralI18n = (locale: string): any => ({
|
||||
getMessage: (key: string) => {
|
||||
const messages: Record<string, string> = {
|
||||
plural_with_value: 'Found %count% results for %query%',
|
||||
};
|
||||
return messages[key] || '';
|
||||
},
|
||||
getUILanguage: () => locale,
|
||||
getBaseMessage: (key: string) => mockPluralI18n('en').getMessage(key),
|
||||
getBaseUILanguage: () => 'en',
|
||||
});
|
||||
|
||||
const pt = translate.createTranslator<any>(
|
||||
mockPluralI18n('en'),
|
||||
solidMessageConstructor,
|
||||
createSolidDefaultValues(),
|
||||
);
|
||||
|
||||
const safeGetPlural = (key: string, number: number, values?: any) => {
|
||||
try {
|
||||
return pt.getPlural(key, number, values);
|
||||
} catch {
|
||||
return key;
|
||||
}
|
||||
};
|
||||
|
||||
// 'query' is missing from values → should return the key
|
||||
const result = safeGetPlural('plural_with_value', 5);
|
||||
expect(result).toBe('plural_with_value');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@solidjs/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { DhcpInterfaces } from 'panel/initialState';
|
||||
import { Ipv4Settings } from 'panel/components/Dhcp/blocks/Ipv4Settings';
|
||||
|
||||
const defaultProps = {
|
||||
v4: {
|
||||
gateway_ip: '',
|
||||
subnet_mask: '',
|
||||
range_start: '',
|
||||
range_end: '',
|
||||
lease_duration: 0,
|
||||
},
|
||||
interfaces: {
|
||||
eth0: {
|
||||
name: 'eth0',
|
||||
flags: 'up',
|
||||
gateway_ip: '192.168.1.1',
|
||||
ip_addresses: ['192.168.1.1'],
|
||||
ipv4_addresses: ['192.168.1.1'],
|
||||
ipv6_addresses: [],
|
||||
hardware_address: '00:00:00:00:00:00',
|
||||
},
|
||||
} as DhcpInterfaces,
|
||||
selectedInterface: 'eth0',
|
||||
processingConfig: false,
|
||||
onSave: vi.fn(),
|
||||
};
|
||||
|
||||
describe('Ipv4Settings', () => {
|
||||
it('renders all fields', () => {
|
||||
render(() => <Ipv4Settings {...defaultProps} />);
|
||||
expect(screen.getByLabelText('Gateway IP address')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('192.168.1.2')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('192.168.1.254')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Subnet mask')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error for gateway IP that is within the DHCP range on blur', async () => {
|
||||
render(() => <Ipv4Settings {...defaultProps} />);
|
||||
|
||||
const gatewayInput = screen.getByLabelText('Gateway IP address');
|
||||
const rangeStartInput = screen.getByPlaceholderText('192.168.1.2');
|
||||
const rangeEndInput = screen.getByPlaceholderText('192.168.1.254');
|
||||
const subnetInput = screen.getByLabelText('Subnet mask');
|
||||
|
||||
// Fill range fields first
|
||||
fireEvent.change(rangeStartInput, { target: { value: '192.168.1.1' } });
|
||||
fireEvent.change(rangeEndInput, { target: { value: '192.168.1.100' } });
|
||||
fireEvent.change(subnetInput, { target: { value: '255.255.255.0' } });
|
||||
|
||||
// Fill gateway inside the range, then blur to trigger validation
|
||||
fireEvent.change(gatewayInput, { target: { value: '192.168.1.50' } });
|
||||
fireEvent.blur(gatewayInput);
|
||||
|
||||
// Should show out-of-range error with the range endpoints
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Must be out of range/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error for range end <= range start on blur', async () => {
|
||||
render(() => <Ipv4Settings {...defaultProps} />);
|
||||
|
||||
const rangeStartInput = screen.getByPlaceholderText('192.168.1.2');
|
||||
const rangeEndInput = screen.getByPlaceholderText('192.168.1.254');
|
||||
const subnetInput = screen.getByLabelText('Subnet mask');
|
||||
|
||||
fireEvent.change(rangeStartInput, { target: { value: '192.168.1.100' } });
|
||||
fireEvent.change(subnetInput, { target: { value: '255.255.255.0' } });
|
||||
fireEvent.change(rangeEndInput, { target: { value: '192.168.1.50' } });
|
||||
fireEvent.blur(rangeEndInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Must be greater than range start')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error for invalid subnet mask on blur', async () => {
|
||||
render(() => <Ipv4Settings {...defaultProps} />);
|
||||
|
||||
const gatewayInput = screen.getByLabelText('Gateway IP address');
|
||||
const subnetInput = screen.getByLabelText('Subnet mask');
|
||||
|
||||
fireEvent.change(gatewayInput, { target: { value: '192.168.1.1' } });
|
||||
fireEvent.change(subnetInput, { target: { value: '999.999.999.999' } });
|
||||
fireEvent.blur(subnetInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid subnet mask')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error for range start outside subnet on blur', async () => {
|
||||
render(() => <Ipv4Settings {...defaultProps} />);
|
||||
|
||||
const gatewayInput = screen.getByLabelText('Gateway IP address');
|
||||
const subnetInput = screen.getByLabelText('Subnet mask');
|
||||
const rangeStartInput = screen.getByPlaceholderText('192.168.1.2');
|
||||
|
||||
fireEvent.change(gatewayInput, { target: { value: '192.168.1.1' } });
|
||||
fireEvent.change(subnetInput, { target: { value: '255.255.255.0' } });
|
||||
fireEvent.change(rangeStartInput, { target: { value: '10.0.0.1' } });
|
||||
fireEvent.blur(rangeStartInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Addresses must be in one subnet')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onSave with valid data', async () => {
|
||||
const onSave = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(() => <Ipv4Settings {...defaultProps} onSave={onSave} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Gateway IP address'), {
|
||||
target: { value: '192.168.1.1' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('Subnet mask'), {
|
||||
target: { value: '255.255.255.0' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.2'), {
|
||||
target: { value: '192.168.1.2' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.254'), {
|
||||
target: { value: '192.168.1.254' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('DHCP lease time (in seconds)'), {
|
||||
target: { value: '86400' },
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
gateway_ip: '192.168.1.1',
|
||||
subnet_mask: '255.255.255.0',
|
||||
range_start: '192.168.1.2',
|
||||
range_end: '192.168.1.254',
|
||||
lease_duration: 86400,
|
||||
});
|
||||
});
|
||||
|
||||
it('clears error on blur after fixing invalid value', async () => {
|
||||
render(() => <Ipv4Settings {...defaultProps} />);
|
||||
|
||||
const rangeStartInput = screen.getByPlaceholderText('192.168.1.2');
|
||||
const rangeEndInput = screen.getByPlaceholderText('192.168.1.254');
|
||||
const subnetInput = screen.getByLabelText('Subnet mask');
|
||||
|
||||
// Trigger an invalid range end error via blur
|
||||
fireEvent.change(rangeStartInput, { target: { value: '192.168.1.100' } });
|
||||
fireEvent.change(subnetInput, { target: { value: '255.255.255.0' } });
|
||||
fireEvent.change(rangeEndInput, { target: { value: '192.168.1.50' } });
|
||||
fireEvent.blur(rangeEndInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Must be greater than range start')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Fix the value and blur again — error should clear via re-validation
|
||||
fireEvent.change(rangeEndInput, { target: { value: '192.168.1.200' } });
|
||||
fireEvent.blur(rangeEndInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Must be greater than range start')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
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: {
|
||||
login: (...args: unknown[]) => mockLogin(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock addErrorToast so it does not depend on other stores.
|
||||
vi.mock('panel/stores/toasts', () => ({
|
||||
addErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock HTML_PAGES constant.
|
||||
vi.mock('panel/helpers/constants', () => ({
|
||||
HTML_PAGES: { LOGIN: '/login.html', MAIN: '/dashboard.html' },
|
||||
}));
|
||||
|
||||
import { processLogin } from '../stores/login';
|
||||
|
||||
describe('processLogin', () => {
|
||||
beforeEach(() => {
|
||||
mockLogin.mockClear();
|
||||
// Prevent window.location.replace from throwing in tests.
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { replace: vi.fn(), origin: 'http://localhost', pathname: '/login.html' },
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('sends { name, password } to the API (not username)', async () => {
|
||||
await processLogin({ name: 'admin', password: 'supersecret' });
|
||||
|
||||
expect(mockLogin).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogin).toHaveBeenCalledWith({ name: 'admin', password: 'supersecret' });
|
||||
});
|
||||
|
||||
it('does not send username field', async () => {
|
||||
await processLogin({ name: 'admin', password: 'supersecret' });
|
||||
|
||||
const sentArg = mockLogin.mock.calls[0][0];
|
||||
expect(sentArg).not.toHaveProperty('username');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, fireEvent } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
|
||||
// CSS modules return {} under css:false, so theme class names would be
|
||||
// undefined. Mock theme with identity class names for deterministic asserts.
|
||||
vi.mock('panel/lib/theme', () => {
|
||||
const make = (names: string[]) => Object.fromEntries(names.map((n) => [n, n]));
|
||||
return {
|
||||
default: {
|
||||
pagination: make([
|
||||
'wrapper',
|
||||
'pagesContainer',
|
||||
'button',
|
||||
'button_active',
|
||||
'arrow',
|
||||
'arrow_left',
|
||||
'arrow_right',
|
||||
'summary',
|
||||
'dropdownText',
|
||||
'dropdownShowOnPage',
|
||||
'limitContainer',
|
||||
]),
|
||||
dropdown: make(['menu', 'item', 'item_active', 'icon', 'flexDropdownWrap', 'dropdown']),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('panel/common/intl', () => ({
|
||||
default: {
|
||||
getMessage: (key: string, values?: { value?: number }) =>
|
||||
values?.value !== undefined ? `${key}:${values.value}` : key,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('panel/common/ui/Dropdown', () => ({
|
||||
Dropdown: (props: any) => (
|
||||
<>
|
||||
<button
|
||||
data-testid="page-size-trigger"
|
||||
onClick={() => props.onOpenChange?.(!props.open)}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
{props.open && <div data-testid="page-size-menu">{props.menu}</div>}
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
import { generatePageNumbers, Pagination } from '../common/ui/Table/blocks/Pagination/Pagination';
|
||||
|
||||
describe('generatePageNumbers', () => {
|
||||
it('returns a single page when totalPages is 1', () => {
|
||||
expect(generatePageNumbers(0, 1)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('shows all pages without ellipsis for a small page count', () => {
|
||||
expect(generatePageNumbers(0, 5)).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it('inserts an ellipsis at the end when on the first page of many', () => {
|
||||
expect(generatePageNumbers(0, 20)).toEqual([1, 2, 3, 'ellipsis', 20]);
|
||||
});
|
||||
|
||||
it('inserts ellipses on both sides when in the middle of many pages', () => {
|
||||
expect(generatePageNumbers(9, 20)).toEqual([
|
||||
1,
|
||||
'ellipsis',
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
'ellipsis',
|
||||
20,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
const baseProps = {
|
||||
pageSize: 10,
|
||||
totalItems: 100,
|
||||
pageSizeOptions: [10, 20, 30],
|
||||
onPageChange: vi.fn(),
|
||||
onPageSizeChange: vi.fn(),
|
||||
};
|
||||
|
||||
const pageButton = (container: HTMLElement, text: string) =>
|
||||
[...container.querySelectorAll('button')].find((b) => b.textContent === text)!;
|
||||
|
||||
describe('Pagination', () => {
|
||||
it('applies the active class only to the current page button', () => {
|
||||
const { container } = render(() => (
|
||||
<Pagination {...baseProps} currentPage={2} totalPages={10} />
|
||||
));
|
||||
// currentPage is 0-based (2) → 1-based page "3" is active
|
||||
expect(pageButton(container, '3').className).toContain('button_active');
|
||||
expect(pageButton(container, '4').className).not.toContain('button_active');
|
||||
expect(pageButton(container, '1').className).not.toContain('button_active');
|
||||
});
|
||||
|
||||
it('moves the active class when currentPage changes reactively', () => {
|
||||
const [current, setCurrent] = createSignal(2);
|
||||
const { container } = render(() => (
|
||||
<Pagination {...baseProps} currentPage={current()} totalPages={10} />
|
||||
));
|
||||
expect(pageButton(container, '3').className).toContain('button_active');
|
||||
setCurrent(3);
|
||||
expect(pageButton(container, '3').className).not.toContain('button_active');
|
||||
expect(pageButton(container, '4').className).toContain('button_active');
|
||||
});
|
||||
|
||||
it('calls onPageChange with the 0-based index when a page button is clicked', () => {
|
||||
const { container } = render(() => (
|
||||
<Pagination {...baseProps} currentPage={0} totalPages={5} />
|
||||
));
|
||||
fireEvent.click(pageButton(container, '2'));
|
||||
expect(baseProps.onPageChange).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('prev/next buttons call onPageChange with currentPage - 1 / + 1', () => {
|
||||
const { container } = render(() => (
|
||||
<Pagination {...baseProps} currentPage={2} totalPages={5} />
|
||||
));
|
||||
const buttons = container.querySelectorAll('button');
|
||||
// buttons: prev, 1..5, next, page-size-trigger (mock)
|
||||
fireEvent.click(buttons[0]); // prev
|
||||
expect(baseProps.onPageChange).toHaveBeenLastCalledWith(1);
|
||||
fireEvent.click(buttons[buttons.length - 2]); // next (before mock trigger)
|
||||
expect(baseProps.onPageChange).toHaveBeenLastCalledWith(3);
|
||||
});
|
||||
|
||||
it('disables prev on the first page and next on the last page', () => {
|
||||
const { container: first } = render(() => (
|
||||
<Pagination {...baseProps} currentPage={0} totalPages={5} />
|
||||
));
|
||||
// buttons: prev, 1..5, next, page-size-trigger (mock)
|
||||
const firstBtns = first.querySelectorAll('button');
|
||||
expect(firstBtns[0]).toBeDisabled();
|
||||
expect(firstBtns[firstBtns.length - 2]).not.toBeDisabled();
|
||||
|
||||
const { container: last } = render(() => (
|
||||
<Pagination {...baseProps} currentPage={4} totalPages={5} />
|
||||
));
|
||||
const lastBtns = last.querySelectorAll('button');
|
||||
expect(lastBtns[0]).not.toBeDisabled();
|
||||
expect(lastBtns[lastBtns.length - 2]).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not render page buttons when totalPages <= 1', () => {
|
||||
const { container } = render(() => (
|
||||
<Pagination {...baseProps} currentPage={0} totalPages={1} />
|
||||
));
|
||||
// Only the page-size trigger button exists (no prev/next/page buttons).
|
||||
const buttons = container.querySelectorAll('button');
|
||||
expect(buttons).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('calls onPageSizeChange when a page-size option is selected', () => {
|
||||
const { getByTestId } = render(() => (
|
||||
<Pagination {...baseProps} currentPage={0} totalPages={3} />
|
||||
));
|
||||
fireEvent.click(getByTestId('page-size-trigger'));
|
||||
fireEvent.click(getByTestId('page-size-menu').querySelectorAll('[class~="item"]')[1]);
|
||||
expect(baseProps.onPageSizeChange).toHaveBeenCalledWith(20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
DEFAULT_LOGS_FILTER,
|
||||
QUERY_LOG_REASON_FILTER,
|
||||
QUERY_LOG_STATUS_FILTER,
|
||||
} from '../helpers/constants';
|
||||
import { getLogsUrlParams } from '../helpers/helpers';
|
||||
|
||||
describe('Query Log filter model', () => {
|
||||
test('stores search, status, and reason separately', () => {
|
||||
expect(DEFAULT_LOGS_FILTER).toEqual({
|
||||
search: '',
|
||||
status: 'all',
|
||||
reason: 'all',
|
||||
});
|
||||
});
|
||||
|
||||
test('serializes all three filter fields into the URL', () => {
|
||||
expect(getLogsUrlParams('example.org', 'blocked', 'FilteredBlockedService')).toBe(
|
||||
'?search=example.org&status=blocked&reason=FilteredBlockedService',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps status and reason option sets separate', () => {
|
||||
expect(QUERY_LOG_STATUS_FILTER.REWRITTEN.QUERY).toBe('rewritten');
|
||||
expect(Object.keys(QUERY_LOG_STATUS_FILTER)).not.toContain('ERROR');
|
||||
expect(QUERY_LOG_REASON_FILTER.BLOCKED_BY_FILTER.QUERY).toBe('FilteredBlackList');
|
||||
expect(QUERY_LOG_REASON_FILTER.SAFE_SEARCH.QUERY).toBe('FilteredSafeSearch');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import intl from 'panel/common/intl';
|
||||
import { FILTERED_STATUS, SPECIAL_FILTER_ID } from '../helpers/constants';
|
||||
import {
|
||||
filterLogsByStatus,
|
||||
getQueryReasonLabel,
|
||||
getQueryReasonDetails,
|
||||
getQueryReasonKey,
|
||||
getQueryStatusLabel,
|
||||
getQueryStatusKey,
|
||||
} from '../components/QueryLog/helpers';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getQueryStatusKey', () => {
|
||||
test('maps safe search and rewrites to rewritten status', () => {
|
||||
expect(getQueryStatusKey(FILTERED_STATUS.FILTERED_SAFE_SEARCH)).toBe('rewritten');
|
||||
expect(getQueryStatusKey(FILTERED_STATUS.REWRITE)).toBe('rewritten');
|
||||
});
|
||||
|
||||
test('maps allowlisted and plain answers to allowed/processed', () => {
|
||||
expect(getQueryStatusKey(FILTERED_STATUS.NOT_FILTERED_WHITE_LIST)).toBe('allowed');
|
||||
expect(getQueryStatusKey(FILTERED_STATUS.NOT_FILTERED_NOT_FOUND)).toBe('processed');
|
||||
});
|
||||
|
||||
test('falls back to rewritten when the row has an original response', () => {
|
||||
expect(
|
||||
getQueryStatusKey('UnexpectedReason', [{ value: '203.0.113.10', type: 'A', ttl: 300 }]),
|
||||
).toBe('rewritten');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQueryReasonKey', () => {
|
||||
test('keeps custom rules separate from blocklist rules', () => {
|
||||
expect(
|
||||
getQueryReasonKey(FILTERED_STATUS.FILTERED_BLACK_LIST, [
|
||||
{
|
||||
filter_list_id: SPECIAL_FILTER_ID.CUSTOM_FILTERING_RULES,
|
||||
text: '||example.org^',
|
||||
},
|
||||
]),
|
||||
).toBe('custom_filtering_rules');
|
||||
|
||||
expect(
|
||||
getQueryReasonKey(FILTERED_STATUS.FILTERED_BLACK_LIST, [
|
||||
{ filter_list_id: 42, text: '||example.org^' },
|
||||
]),
|
||||
).toBe('blocked_by_filter');
|
||||
});
|
||||
|
||||
test('maps threat, parental, safe-search, allowlist, and rewrite reasons', () => {
|
||||
expect(getQueryReasonKey(FILTERED_STATUS.FILTERED_SAFE_BROWSING, [])).toBe(
|
||||
'blocked_threats',
|
||||
);
|
||||
expect(getQueryReasonKey(FILTERED_STATUS.FILTERED_PARENTAL, [])).toBe(
|
||||
'blocked_by_parental_control',
|
||||
);
|
||||
expect(getQueryReasonKey(FILTERED_STATUS.FILTERED_SAFE_SEARCH, [])).toBe('safe_search');
|
||||
expect(getQueryReasonKey(FILTERED_STATUS.NOT_FILTERED_WHITE_LIST, [])).toBe('allowlists');
|
||||
expect(getQueryReasonKey(FILTERED_STATUS.REWRITE, [])).toBe('dns_rewrites');
|
||||
});
|
||||
});
|
||||
|
||||
describe('query log label helpers', () => {
|
||||
test('maps status keys through static intl keys', () => {
|
||||
const getMessageSpy = vi.spyOn(intl, 'getMessage').mockImplementation((key) => key);
|
||||
|
||||
expect(getQueryStatusLabel('blocked')).toBe('query_log_blocked');
|
||||
expect(getQueryStatusLabel('allowed')).toBe('query_log_allowed');
|
||||
expect(getQueryStatusLabel('processed')).toBe('query_log_processed');
|
||||
expect(getQueryStatusLabel('rewritten')).toBe('query_log_rewritten');
|
||||
expect(getQueryStatusLabel('error')).toBe('error');
|
||||
|
||||
expect(getMessageSpy).toHaveBeenNthCalledWith(1, 'query_log_blocked');
|
||||
expect(getMessageSpy).toHaveBeenNthCalledWith(2, 'query_log_allowed');
|
||||
expect(getMessageSpy).toHaveBeenNthCalledWith(3, 'query_log_processed');
|
||||
expect(getMessageSpy).toHaveBeenNthCalledWith(4, 'query_log_rewritten');
|
||||
expect(getMessageSpy).toHaveBeenNthCalledWith(5, 'error');
|
||||
});
|
||||
|
||||
test('maps reason keys through static intl keys and keeps none literal', () => {
|
||||
const getMessageSpy = vi.spyOn(intl, 'getMessage').mockImplementation((key) => key);
|
||||
|
||||
expect(getQueryReasonLabel('none')).toBe('-');
|
||||
expect(getQueryReasonLabel('blocked_by_filter')).toBe('query_log_blocked_by_filter');
|
||||
expect(getQueryReasonLabel('custom_filtering_rules')).toBe(
|
||||
'query_log_custom_filtering_rules',
|
||||
);
|
||||
expect(getQueryReasonLabel('blocked_services')).toBe('query_log_blocked_services');
|
||||
expect(getQueryReasonLabel('blocked_threats')).toBe('query_log_blocked_threats');
|
||||
expect(getQueryReasonLabel('blocked_by_parental_control')).toBe(
|
||||
'query_log_blocked_by_parental_control',
|
||||
);
|
||||
expect(getQueryReasonLabel('safe_search')).toBe('query_log_safe_search');
|
||||
expect(getQueryReasonLabel('dns_rewrites')).toBe('dns_rewrites');
|
||||
expect(getQueryReasonLabel('allowlists')).toBe('allowlists');
|
||||
expect(getQueryReasonLabel('error')).toBe('error');
|
||||
|
||||
expect(getMessageSpy).toHaveBeenNthCalledWith(1, 'query_log_blocked_by_filter');
|
||||
expect(getMessageSpy).toHaveBeenNthCalledWith(2, 'query_log_custom_filtering_rules');
|
||||
expect(getMessageSpy).toHaveBeenNthCalledWith(3, 'query_log_blocked_services');
|
||||
expect(getMessageSpy).toHaveBeenNthCalledWith(4, 'query_log_blocked_threats');
|
||||
expect(getMessageSpy).toHaveBeenNthCalledWith(5, 'query_log_blocked_by_parental_control');
|
||||
expect(getMessageSpy).toHaveBeenNthCalledWith(6, 'query_log_safe_search');
|
||||
expect(getMessageSpy).toHaveBeenNthCalledWith(7, 'dns_rewrites');
|
||||
expect(getMessageSpy).toHaveBeenNthCalledWith(8, 'allowlists');
|
||||
expect(getMessageSpy).toHaveBeenNthCalledWith(9, 'error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQueryReasonDetails', () => {
|
||||
const filters = [
|
||||
{
|
||||
enabled: true,
|
||||
id: 42,
|
||||
lastUpdated: '',
|
||||
name: 'AdGuard DNS filter',
|
||||
rulesCount: 0,
|
||||
url: '',
|
||||
},
|
||||
];
|
||||
|
||||
const whitelistFilters = [
|
||||
{
|
||||
enabled: true,
|
||||
id: 7,
|
||||
lastUpdated: '',
|
||||
name: "Dan Pollock's List",
|
||||
rulesCount: 0,
|
||||
url: '',
|
||||
},
|
||||
];
|
||||
|
||||
const services = [{ id: 'amazon', name: 'Amazon' }];
|
||||
|
||||
test('returns filter names for blocklist and allowlist reasons', () => {
|
||||
expect(
|
||||
getQueryReasonDetails({
|
||||
elapsedMs: '17',
|
||||
filters,
|
||||
reason: FILTERED_STATUS.FILTERED_BLACK_LIST,
|
||||
rules: [{ filter_list_id: 42, text: '||example.org^' }],
|
||||
serviceName: '',
|
||||
services,
|
||||
whitelistFilters,
|
||||
}),
|
||||
).toBe('AdGuard DNS filter');
|
||||
|
||||
expect(
|
||||
getQueryReasonDetails({
|
||||
elapsedMs: '31',
|
||||
filters,
|
||||
reason: FILTERED_STATUS.NOT_FILTERED_WHITE_LIST,
|
||||
rules: [{ filter_list_id: 7, text: '@@||example.org^' }],
|
||||
serviceName: '',
|
||||
services,
|
||||
whitelistFilters,
|
||||
}),
|
||||
).toBe("Dan Pollock's List");
|
||||
});
|
||||
|
||||
test('returns the service name for blocked-services rows', () => {
|
||||
expect(
|
||||
getQueryReasonDetails({
|
||||
elapsedMs: '37',
|
||||
filters,
|
||||
reason: FILTERED_STATUS.FILTERED_BLOCKED_SERVICE,
|
||||
rules: [],
|
||||
serviceName: 'amazon',
|
||||
services,
|
||||
whitelistFilters,
|
||||
}),
|
||||
).toBe('Amazon');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterLogsByStatus', () => {
|
||||
const logs = [
|
||||
{ reason: FILTERED_STATUS.NOT_FILTERED_NOT_FOUND, originalResponse: [] },
|
||||
{ reason: FILTERED_STATUS.FILTERED_BLACK_LIST, originalResponse: [] },
|
||||
{ reason: FILTERED_STATUS.NOT_FILTERED_WHITE_LIST, originalResponse: [] },
|
||||
{ reason: FILTERED_STATUS.REWRITE, originalResponse: [] },
|
||||
] as any[];
|
||||
|
||||
test('filters logs by generic status category', () => {
|
||||
expect(filterLogsByStatus(logs, 'processed')).toHaveLength(1);
|
||||
expect(filterLogsByStatus(logs, 'blocked')).toHaveLength(1);
|
||||
expect(filterLogsByStatus(logs, 'allowed')).toHaveLength(1);
|
||||
expect(filterLogsByStatus(logs, 'rewritten')).toHaveLength(1);
|
||||
expect(filterLogsByStatus(logs, 'all')).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { getAdditionalLogs, setFilteredLogs, queryLogsState } from 'panel/stores/queryLogs';
|
||||
import { apiClient } from 'panel/api/Api';
|
||||
|
||||
vi.mock('panel/api/Api', () => ({
|
||||
apiClient: {
|
||||
getQueryLog: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('panel/stores/toasts', () => ({
|
||||
addErrorToast: vi.fn(),
|
||||
addSuccessToast: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('queryLogs store', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('getAdditionalLogs appends filtered rows using the oldest cursor and filter', async () => {
|
||||
(apiClient.getQueryLog 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({
|
||||
data: fullPage,
|
||||
oldest: 'cur',
|
||||
});
|
||||
await setFilteredLogs({ search: '', status: 'rewritten', reason: 'all' });
|
||||
expect(queryLogsState.isEntireLog).toBe(false);
|
||||
expect(queryLogsState.logs).toHaveLength(20);
|
||||
|
||||
// Load more: one extra rewritten entry, then end of log.
|
||||
(apiClient.getQueryLog as any).mockResolvedValueOnce({
|
||||
data: [{ reason: 'Rewrite', question: {} }],
|
||||
oldest: '',
|
||||
});
|
||||
|
||||
await getAdditionalLogs();
|
||||
|
||||
// The load-more request must carry the cursor and the reason filter.
|
||||
expect(apiClient.getQueryLog).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
older_than: 'cur',
|
||||
reason: expect.arrayContaining([
|
||||
'Rewrite',
|
||||
'RewriteEtcHosts',
|
||||
'RewriteRule',
|
||||
'FilteredSafeSearch',
|
||||
]),
|
||||
}),
|
||||
);
|
||||
// Must NOT send the deprecated response_status
|
||||
const lastCall = (apiClient.getQueryLog as any).mock.calls.at(-1)[0];
|
||||
expect(lastCall).not.toHaveProperty('response_status');
|
||||
|
||||
expect(queryLogsState.logs).toHaveLength(21);
|
||||
expect(queryLogsState.isEntireLog).toBe(true);
|
||||
expect(queryLogsState.processingAdditionalLogs).toBe(false);
|
||||
});
|
||||
|
||||
it('setFilteredLogs sends reason strings for blocked status', async () => {
|
||||
(apiClient.getQueryLog as any).mockReset();
|
||||
(apiClient.getQueryLog 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(
|
||||
expect.objectContaining({
|
||||
reason: expect.arrayContaining([
|
||||
'FilteredBlackList',
|
||||
'FilteredSafeBrowsing',
|
||||
'FilteredParental',
|
||||
'FilteredBlockedService',
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(lastCall).not.toHaveProperty('response_status');
|
||||
});
|
||||
|
||||
it('does not mark the log as complete when additional loading stops', async () => {
|
||||
(apiClient.getQueryLog as any).mockResolvedValue({
|
||||
data: [],
|
||||
oldest: 'next-cursor',
|
||||
});
|
||||
|
||||
await getAdditionalLogs();
|
||||
|
||||
expect(queryLogsState.processingAdditionalLogs).toBe(false);
|
||||
expect(queryLogsState.isEntireLog).toBe(false);
|
||||
});
|
||||
|
||||
it('accumulates pages until oldest is empty (short-polling)', async () => {
|
||||
(apiClient.getQueryLog as any)
|
||||
.mockResolvedValueOnce({
|
||||
data: [{ reason: 'Rewrite' }],
|
||||
oldest: 'cur',
|
||||
is_entire_log: false,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: [{ reason: 'Rewrite' }],
|
||||
oldest: '',
|
||||
is_entire_log: true,
|
||||
});
|
||||
|
||||
await setFilteredLogs({ search: '', status: 'rewritten', reason: 'all' });
|
||||
expect(apiClient.getQueryLog).toHaveBeenCalledTimes(2);
|
||||
expect(queryLogsState.processingGetLogs).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
deleteRewrite: vi.fn(),
|
||||
getRewritesList: vi.fn(),
|
||||
addSuccessToast: vi.fn(),
|
||||
addErrorToast: vi.fn(),
|
||||
getMessage: vi.fn((key: string, values?: any) => {
|
||||
if (values?.key) return `${key} ${values.key}`;
|
||||
return key;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('panel/api/Api', () => ({
|
||||
apiClient: {
|
||||
deleteRewrite: mocks.deleteRewrite,
|
||||
getRewritesList: mocks.getRewritesList,
|
||||
},
|
||||
}));
|
||||
vi.mock('panel/stores/toasts', () => ({
|
||||
addSuccessToast: mocks.addSuccessToast,
|
||||
addErrorToast: mocks.addErrorToast,
|
||||
}));
|
||||
vi.mock('panel/common/intl', () => ({
|
||||
default: {
|
||||
getMessage: mocks.getMessage,
|
||||
},
|
||||
}));
|
||||
|
||||
import { deleteRewrite } from 'panel/stores/rewrites';
|
||||
|
||||
describe('deleteRewrite', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('shows rewrite_deleted toast with the domain (FR-009)', async () => {
|
||||
mocks.deleteRewrite.mockResolvedValue(undefined);
|
||||
mocks.getRewritesList.mockResolvedValue([]);
|
||||
await deleteRewrite({
|
||||
domain: 'example.com',
|
||||
answer: '1.1.1.1',
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('honors showToast:false', async () => {
|
||||
mocks.deleteRewrite.mockResolvedValue(undefined);
|
||||
mocks.getRewritesList.mockResolvedValue([]);
|
||||
await deleteRewrite({ domain: 'example.com', answer: '1.1.1.1', enabled: true });
|
||||
expect(mocks.addSuccessToast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import {
|
||||
msToTime,
|
||||
timeToMs,
|
||||
formatTimePeriod,
|
||||
isFullDay,
|
||||
FULL_DAY_END_MS,
|
||||
DAYS_OF_WEEK,
|
||||
getNextTimeValue,
|
||||
getNormalizedEndTime,
|
||||
getEndTimeOptions,
|
||||
} from '../components/BlockedServices/InactivitySchedule/helpers';
|
||||
|
||||
describe('schedule helpers', () => {
|
||||
describe('msToTime', () => {
|
||||
test('converts 0ms to 00:00', () => {
|
||||
expect(msToTime(0)).toEqual({ hours: 0, minutes: 0 });
|
||||
});
|
||||
|
||||
test('converts 3600000ms to 01:00', () => {
|
||||
expect(msToTime(3600000)).toEqual({ hours: 1, minutes: 0 });
|
||||
});
|
||||
|
||||
test('converts 86340000ms to 23:59', () => {
|
||||
expect(msToTime(86340000)).toEqual({ hours: 23, minutes: 59 });
|
||||
});
|
||||
|
||||
test('converts 45060000ms to 12:31', () => {
|
||||
expect(msToTime(45060000)).toEqual({ hours: 12, minutes: 31 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeToMs', () => {
|
||||
test('converts 00:00 to 0ms', () => {
|
||||
expect(timeToMs(0, 0)).toBe(0);
|
||||
});
|
||||
|
||||
test('converts 01:00 to 3600000ms', () => {
|
||||
expect(timeToMs(1, 0)).toBe(3600000);
|
||||
});
|
||||
|
||||
test('converts 23:59 to 86340000ms', () => {
|
||||
expect(timeToMs(23, 59)).toBe(86340000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTimePeriod', () => {
|
||||
test('formats start and end as HH:MM – HH:MM', () => {
|
||||
expect(formatTimePeriod(3600000, 64800000)).toBe('01:00 \u2013 18:00');
|
||||
});
|
||||
|
||||
test('pads single digits', () => {
|
||||
expect(formatTimePeriod(0, 3660000)).toBe('00:00 \u2013 01:01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextTimeValue', () => {
|
||||
test('returns the next minute within the same hour', () => {
|
||||
expect(getNextTimeValue({ hours: 10, minutes: 15 })).toEqual({
|
||||
hours: 10,
|
||||
minutes: 16,
|
||||
});
|
||||
});
|
||||
|
||||
test('rolls over to the next hour', () => {
|
||||
expect(getNextTimeValue({ hours: 10, minutes: 59 })).toEqual({
|
||||
hours: 11,
|
||||
minutes: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null for the last minute of the day', () => {
|
||||
expect(getNextTimeValue({ hours: 23, minutes: 59 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNormalizedEndTime', () => {
|
||||
test('keeps a valid end time unchanged', () => {
|
||||
expect(
|
||||
getNormalizedEndTime({ hours: 10, minutes: 15 }, { hours: 11, minutes: 0 }),
|
||||
).toEqual({ hours: 11, minutes: 0 });
|
||||
});
|
||||
|
||||
test('moves an invalid end time to the next available minute', () => {
|
||||
expect(
|
||||
getNormalizedEndTime({ hours: 10, minutes: 15 }, { hours: 10, minutes: 15 }),
|
||||
).toEqual({ hours: 10, minutes: 16 });
|
||||
});
|
||||
|
||||
test('returns null when no later end time exists', () => {
|
||||
expect(
|
||||
getNormalizedEndTime({ hours: 23, minutes: 59 }, { hours: 23, minutes: 59 }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEndTimeOptions', () => {
|
||||
test('disables end hours earlier than the selected start hour', () => {
|
||||
const options = getEndTimeOptions({ hours: 10, minutes: 15 }, 11);
|
||||
|
||||
expect(options.hours[9].isDisabled).toBe(true);
|
||||
expect(options.hours[10].isDisabled).toBe(false);
|
||||
expect(options.hours[11].isDisabled).toBe(false);
|
||||
});
|
||||
|
||||
test('disables end minutes not later than the selected start minute in the same hour', () => {
|
||||
const options = getEndTimeOptions({ hours: 10, minutes: 15 }, 10);
|
||||
|
||||
expect(options.minutes[15].isDisabled).toBe(true);
|
||||
expect(options.minutes[16].isDisabled).toBe(false);
|
||||
});
|
||||
|
||||
test('disables all end options when start time is 23:59', () => {
|
||||
const options = getEndTimeOptions({ hours: 23, minutes: 59 }, 23);
|
||||
|
||||
expect(options.hasAvailableEndTime).toBe(false);
|
||||
expect(options.hours.every((option) => option.isDisabled)).toBe(true);
|
||||
expect(options.minutes.every((option) => option.isDisabled)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFullDay', () => {
|
||||
test('returns true for start=0, end=86340000', () => {
|
||||
expect(isFullDay(0, 86340000)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for partial day', () => {
|
||||
expect(isFullDay(0, 3600000)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FULL_DAY_END_MS', () => {
|
||||
test('equals 86340000', () => {
|
||||
expect(FULL_DAY_END_MS).toBe(86340000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DAYS_OF_WEEK', () => {
|
||||
test('has 7 days in correct order', () => {
|
||||
expect(DAYS_OF_WEEK).toEqual(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { render } from '@solidjs/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Select } from '../common/controls/Select/Select';
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: '0.0.0.0', label: 'All interfaces' },
|
||||
{ value: '127.0.0.1', label: 'Loopback' },
|
||||
];
|
||||
|
||||
describe('Select', () => {
|
||||
it('opens the menu when clicking anywhere on the control (not just text)', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(() => (
|
||||
<Select
|
||||
options={OPTIONS}
|
||||
value={OPTIONS[0]}
|
||||
onChange={() => {}}
|
||||
placeholder="Select interface"
|
||||
id="test-select"
|
||||
/>
|
||||
));
|
||||
|
||||
// The trigger button should contain both the value text and the indicator.
|
||||
const trigger = document.querySelector(
|
||||
'[data-scope="select"][data-part="trigger"]',
|
||||
) as HTMLElement;
|
||||
|
||||
expect(trigger).toBeTruthy();
|
||||
|
||||
// Click on the trigger (the whole button area).
|
||||
await user.click(trigger);
|
||||
|
||||
// Menu content should appear.
|
||||
const content = document.querySelector('[data-scope="select"][data-part="content"]');
|
||||
expect(content).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the indicator inside the trigger button', () => {
|
||||
render(() => (
|
||||
<Select
|
||||
options={OPTIONS}
|
||||
value={OPTIONS[0]}
|
||||
onChange={() => {}}
|
||||
placeholder="Select interface"
|
||||
/>
|
||||
));
|
||||
|
||||
const trigger = document.querySelector(
|
||||
'[data-scope="select"][data-part="trigger"]',
|
||||
) as HTMLElement;
|
||||
const indicator = trigger?.querySelector('[data-part="indicator"]');
|
||||
|
||||
// The indicator (arrow icon) should be inside the trigger.
|
||||
expect(indicator).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders menuFooter inside the dropdown content', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(() => (
|
||||
<Select
|
||||
options={OPTIONS}
|
||||
value={OPTIONS[0]}
|
||||
onChange={() => {}}
|
||||
menuFooter={<div data-testid="select-footer">Footer</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
const trigger = document.querySelector(
|
||||
'[data-scope="select"][data-part="trigger"]',
|
||||
) as HTMLElement;
|
||||
await user.click(trigger);
|
||||
|
||||
const footer = document.querySelector('[data-testid="select-footer"]');
|
||||
expect(footer).toBeTruthy();
|
||||
expect(footer?.closest('[data-scope="select"][data-part="content"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { afterEach } from 'vitest';
|
||||
import { cleanup } from '@solidjs/testing-library';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getStats: vi.fn(),
|
||||
searchClients: vi.fn(),
|
||||
addErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('panel/api/Api', () => ({
|
||||
apiClient: {
|
||||
getStats: mocks.getStats,
|
||||
searchClients: mocks.searchClients,
|
||||
},
|
||||
}));
|
||||
vi.mock('panel/stores/toasts', () => ({
|
||||
addErrorToast: mocks.addErrorToast,
|
||||
}));
|
||||
|
||||
import { getStats, statsState } from 'panel/stores/stats';
|
||||
|
||||
describe('getStats', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.searchClients.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('enriches top clients and stores normalizedTopClients (FR-001/019)', async () => {
|
||||
mocks.getStats.mockResolvedValue({
|
||||
top_clients: [{ '1.2.3.4': 5 }],
|
||||
avg_processing_time: 0.012,
|
||||
top_blocked_domains: [],
|
||||
top_queried_domains: [],
|
||||
top_upstreams_avg_time: [],
|
||||
top_upstreams_responses: [],
|
||||
});
|
||||
|
||||
await getStats();
|
||||
|
||||
// searchClients was called with discovered client ids
|
||||
expect(mocks.searchClients).toHaveBeenCalledWith({
|
||||
clients: [{ id: '1.2.3.4' }],
|
||||
});
|
||||
// normalizedTopClients is populated (configured bucket carries info name)
|
||||
expect(Object.keys(statsState.normalizedTopClients.auto)).toContain('1.2.3.4');
|
||||
});
|
||||
|
||||
it('converts avg_processing_time to milliseconds, falsy passthrough (FR-002)', async () => {
|
||||
mocks.getStats.mockResolvedValue({ avg_processing_time: 0.012 });
|
||||
await getStats();
|
||||
expect(statsState.avgProcessingTime).toBe(12);
|
||||
|
||||
mocks.getStats.mockResolvedValue({ avg_processing_time: 0 });
|
||||
await getStats();
|
||||
expect(statsState.avgProcessingTime).toBe(0); // not NaN
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@solidjs/testing-library';
|
||||
import Toast from '../components/Toasts/Toast';
|
||||
|
||||
describe('Toast', () => {
|
||||
it('renders the plain message when no options', () => {
|
||||
const { getByText } = render(() => <Toast id="1" message="hello" type="success" />);
|
||||
expect(getByText('hello')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders interpolated options.components content', () => {
|
||||
const { container } = render(() => (
|
||||
<Toast
|
||||
id="2"
|
||||
message="update_failed"
|
||||
type="notice"
|
||||
options={{ components: { a: (c: string) => c } }}
|
||||
/>
|
||||
));
|
||||
// Message rendered (not [object Object])
|
||||
expect(container.textContent).not.toContain('[object Object]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
addErrorToast,
|
||||
addSuccessToast,
|
||||
addNoticeToast,
|
||||
removeToast,
|
||||
toastsState,
|
||||
} from '../stores/toasts';
|
||||
|
||||
describe('toasts store', () => {
|
||||
beforeEach(() => {
|
||||
// Clear all notices between tests.
|
||||
toastsState.notices.forEach((n: any) => removeToast(n.id));
|
||||
});
|
||||
|
||||
it('addNoticeToast extracts error.toString() (no [object Object])', () => {
|
||||
addNoticeToast({ error: 'update_failed' });
|
||||
const last = toastsState.notices[toastsState.notices.length - 1];
|
||||
expect(last.message).toBe('update_failed');
|
||||
expect(last.type).toBe('notice');
|
||||
});
|
||||
|
||||
it('addNoticeToast preserves options', () => {
|
||||
const options = { components: { a: () => {} } };
|
||||
addNoticeToast({ error: 'update_failed', options });
|
||||
const last = toastsState.notices[toastsState.notices.length - 1];
|
||||
expect(last.options).toEqual(options);
|
||||
});
|
||||
|
||||
it('addErrorToast preserves options and action', () => {
|
||||
const action = { text: 'retry', callback: () => {} };
|
||||
addErrorToast({ error: 'boom', options: { x: 1 }, action });
|
||||
const last = toastsState.notices[toastsState.notices.length - 1];
|
||||
expect(last.message).toBe('boom');
|
||||
expect(last.options).toEqual({ x: 1 });
|
||||
expect(last.action).toEqual(action);
|
||||
expect(last.type).toBe('error');
|
||||
});
|
||||
|
||||
it('addSuccessToast preserves code on object payload', () => {
|
||||
addSuccessToast({ message: 'notify_updated', code: 'notify_updated' });
|
||||
const last = toastsState.notices[toastsState.notices.length - 1];
|
||||
expect(last.code).toBe('notify_updated');
|
||||
});
|
||||
|
||||
it('addSuccessToast accepts a bare string', () => {
|
||||
addSuccessToast('config_successfully_saved');
|
||||
const last = toastsState.notices[toastsState.notices.length - 1];
|
||||
expect(last.message).toBe('config_successfully_saved');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { toggleBlocking, BLOCK_ACTIONS, filteringState } from 'panel/stores/filtering';
|
||||
|
||||
let lastSetRules = '';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiSetRules: vi.fn((params: any) => {
|
||||
lastSetRules = params?.rules || '';
|
||||
return Promise.resolve(undefined);
|
||||
}),
|
||||
apiGetFilteringStatus: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
user_rules: lastSetRules,
|
||||
filters: [],
|
||||
whitelist_filters: [],
|
||||
interval: 24,
|
||||
enabled: true,
|
||||
}),
|
||||
),
|
||||
addSuccessToast: vi.fn(),
|
||||
addErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('panel/api/Api', () => ({
|
||||
apiClient: {
|
||||
setRules: mocks.apiSetRules,
|
||||
getFilteringStatus: mocks.apiGetFilteringStatus,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('panel/stores/toasts', () => ({
|
||||
addSuccessToast: mocks.addSuccessToast,
|
||||
addErrorToast: mocks.addErrorToast,
|
||||
createUndoToast: (message: any, actionLabel: string, onUndo: () => void | Promise<void>) => ({
|
||||
message,
|
||||
actionLabel,
|
||||
undoId: 'test-undo-id',
|
||||
onUndo,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('toggleBlocking', () => {
|
||||
beforeEach(async () => {
|
||||
lastSetRules = '';
|
||||
mocks.apiSetRules.mockClear();
|
||||
mocks.addSuccessToast.mockClear();
|
||||
mocks.addErrorToast.mockClear();
|
||||
// Reset store state - the default mock impl returns lastSetRules which is ''
|
||||
const { setRules } = await import('panel/stores/filtering');
|
||||
await setRules('');
|
||||
});
|
||||
|
||||
it('adds a blocking rule when no rule exists', async () => {
|
||||
const result = await toggleBlocking(BLOCK_ACTIONS.BLOCK, 'example.com');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mocks.apiSetRules).toHaveBeenCalled();
|
||||
expect(filteringState.userRules).toContain('||example.com^$important');
|
||||
});
|
||||
|
||||
it('replaces an allowlist rule with a blocking rule', async () => {
|
||||
// Set up initial state with an allowlist rule
|
||||
const { setRules } = await import('panel/stores/filtering');
|
||||
await setRules('@@||allowed.example^$important\n');
|
||||
// After setRules, lastSetRules = '@@||allowed.example^$important\n'
|
||||
// and filteringState.userRules should match
|
||||
|
||||
const result = await toggleBlocking(BLOCK_ACTIONS.BLOCK, 'allowed.example');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(filteringState.userRules).toContain('||allowed.example^$important');
|
||||
});
|
||||
|
||||
it('replaces a blocking rule with an allowlist rule', async () => {
|
||||
const { setRules } = await import('panel/stores/filtering');
|
||||
await setRules('||blocked.example^$important\n');
|
||||
|
||||
const result = await toggleBlocking(BLOCK_ACTIONS.UNBLOCK, 'blocked.example');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(filteringState.userRules).toContain('@@||blocked.example^$important');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,746 @@
|
||||
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);
|
||||
@@ -0,0 +1,65 @@
|
||||
.checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
gap: 8px;
|
||||
|
||||
&.disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&.center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&.start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&.end {
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.input:disabled + .handler .icon {
|
||||
color: var(--disabled-gray-icons);
|
||||
}
|
||||
|
||||
.input:disabled + .handler .icon_active {
|
||||
color: var(--disabled-main-button);
|
||||
}
|
||||
|
||||
.handler {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
&.plus {
|
||||
background-color: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
font-weight: var(--weight-regular);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--default-gray-icons);
|
||||
}
|
||||
|
||||
.active {
|
||||
color: var(--default-product-icon);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { type JSX, Show } from 'solid-js';
|
||||
import cn from 'clsx';
|
||||
import theme from 'panel/lib/theme';
|
||||
import { Icon } from 'panel/common/ui/Icon';
|
||||
|
||||
import s from './Checkbox.module.pcss';
|
||||
|
||||
type Props = JSX.InputHTMLAttributes<HTMLInputElement> & {
|
||||
class?: string;
|
||||
labelClass?: string;
|
||||
overflow?: boolean;
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
children?: JSX.Element;
|
||||
plusStyle?: boolean;
|
||||
verticalAlign?: 'center' | 'start' | 'end';
|
||||
ref?: HTMLInputElement | ((el: HTMLInputElement) => void);
|
||||
};
|
||||
|
||||
export const Checkbox = (props: Props) => {
|
||||
const setRef = (el: HTMLInputElement) => {
|
||||
if (typeof props.ref === 'function') {
|
||||
props.ref(el);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<label
|
||||
for={props.id}
|
||||
class={cn(
|
||||
s.checkbox,
|
||||
{ [s.disabled]: props.disabled },
|
||||
props.verticalAlign && s[props.verticalAlign],
|
||||
props.class,
|
||||
)}
|
||||
onClick={(e) => (props.onClick as any)?.(e)}
|
||||
>
|
||||
<input
|
||||
ref={(el) => setRef(el)}
|
||||
id={props.id}
|
||||
name={props.name}
|
||||
type="checkbox"
|
||||
class={s.input}
|
||||
onChange={(e) => (props.onChange as any)?.(e)}
|
||||
checked={props.checked}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
<div class={s.handler}>
|
||||
<Show
|
||||
when={props.plusStyle}
|
||||
fallback={
|
||||
<Icon
|
||||
icon={(props.checked ? 'checkbox_on' : 'checkbox_off') as any}
|
||||
class={cn(s.icon, { [s.active]: props.checked })}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
icon={(props.checked ? 'checkbox_minus' : 'checkbox_plus') as any}
|
||||
class={cn(s.icon, { [s.active]: props.checked })}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.children}>
|
||||
<div
|
||||
class={cn(
|
||||
s.label,
|
||||
{ [theme.common.textOverflow]: props.overflow },
|
||||
props.labelClass,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</Show>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { Checkbox } from './Checkbox';
|
||||
@@ -0,0 +1,167 @@
|
||||
.inputWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
background-color: var(--default-page-background);
|
||||
height: 48px;
|
||||
overflow: hidden;
|
||||
font-size: 16px;
|
||||
border: 1px solid var(--default-item-divider);
|
||||
border-radius: 8px;
|
||||
|
||||
&.focused {
|
||||
border-color: var(--pressed-link);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background-color: var(--pressed-page-background);
|
||||
|
||||
.input {
|
||||
background-color: var(--pressed-page-background);
|
||||
color: var(--default-description-text);
|
||||
cursor: default;
|
||||
|
||||
&:-webkit-autofill,
|
||||
&:-webkit-autofill:hover,
|
||||
&:-webkit-autofill:focus {
|
||||
box-shadow: 0 0 0 100px var(--pressed-page-background) inset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.small {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
&.large {
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
&.borderless {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-color: var(--default-error-icon);
|
||||
}
|
||||
|
||||
&.invalid {
|
||||
border: 1px solid var(--default-error-icon);
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active {
|
||||
border: 1px solid var(--default-error-icon);
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
color: var(--default-main-text);
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
border: 0;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--default-page-background);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--default-placeholder);
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:-webkit-autofill,
|
||||
&:-webkit-autofill:hover,
|
||||
&:-webkit-autofill:focus {
|
||||
box-shadow: 0 0 0 100px var(--default-page-background) inset;
|
||||
-webkit-text-fill-color: var(--default-main-text);
|
||||
caret-color: var(--default-main-text);
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.prefix {
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.suffix {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.postfix {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 100%;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--default-gray-icons);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.suffixIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inputLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--default-description-text);
|
||||
}
|
||||
|
||||
.inputError {
|
||||
font-size: 14px;
|
||||
color: var(--default-error-icon);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.inputSuffix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inputIconButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--default-description-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inputIconButton:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { type JSX, createSignal, createEffect, Show, untrack } from 'solid-js';
|
||||
import cn from 'clsx';
|
||||
import { Icon } from 'panel/common/ui/Icon';
|
||||
|
||||
import intl from 'panel/common/intl';
|
||||
import s from './Input.module.pcss';
|
||||
|
||||
type Props = Omit<JSX.InputHTMLAttributes<HTMLInputElement>, 'size' | 'onChange' | 'onBlur'> & {
|
||||
label?: JSX.Element;
|
||||
class?: string;
|
||||
innerClass?: string;
|
||||
prefixIcon?: JSX.Element;
|
||||
suffixIcon?: JSX.Element;
|
||||
borderless?: boolean;
|
||||
invalid?: boolean;
|
||||
maxLength?: number;
|
||||
error?: boolean;
|
||||
errorMessage?: string;
|
||||
isClearable?: boolean;
|
||||
onClear?: () => void;
|
||||
inputError?: string;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
value?: string | number | readonly string[];
|
||||
defaultValue?: string;
|
||||
onChange?: (event: Event) => void;
|
||||
onBlur?: (event: FocusEvent) => void;
|
||||
ref?: HTMLInputElement | ((el: HTMLInputElement) => void);
|
||||
};
|
||||
|
||||
const hasInputValue = (value: string | number | readonly string[] | undefined) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
return String(value ?? '').length > 0;
|
||||
};
|
||||
|
||||
export const Input = (props: Props) => {
|
||||
let inputRef: HTMLInputElement | undefined;
|
||||
const [focused, setFocused] = createSignal(false);
|
||||
const [hasValue, setHasValue] = createSignal(
|
||||
hasInputValue(untrack(() => props.value) ?? untrack(() => props.defaultValue)),
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
if (props.value !== undefined) {
|
||||
setHasValue(hasInputValue(props.value));
|
||||
}
|
||||
});
|
||||
|
||||
const setInputRef = (el: HTMLInputElement) => {
|
||||
inputRef = el;
|
||||
if (typeof props.ref === 'function') {
|
||||
props.ref(el);
|
||||
}
|
||||
};
|
||||
|
||||
const showClearButton = () =>
|
||||
Boolean(props.isClearable && !props.disabled && !props.readOnly && hasValue());
|
||||
const hasActions = () => Boolean(props.suffixIcon || showClearButton());
|
||||
|
||||
const handleChange = (event: Event) => {
|
||||
setHasValue((event.currentTarget as HTMLInputElement).value.length > 0);
|
||||
props.onChange?.(event);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
if (!inputRef) {
|
||||
props.onClear?.();
|
||||
setHasValue(false);
|
||||
return;
|
||||
}
|
||||
|
||||
inputRef.value = '';
|
||||
props.onChange?.({
|
||||
target: inputRef,
|
||||
currentTarget: inputRef,
|
||||
} as unknown as Event);
|
||||
|
||||
setHasValue(false);
|
||||
props.onClear?.();
|
||||
};
|
||||
|
||||
const computedErrorMessage = () => props.inputError ?? props.errorMessage;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={props.label}>
|
||||
<label class={s.inputLabel} for={props.id}>
|
||||
{props.label}
|
||||
</label>
|
||||
</Show>
|
||||
<div
|
||||
class={cn(
|
||||
s.inputWrapper,
|
||||
{
|
||||
[s.borderless]: props.borderless,
|
||||
[s.small]: props.size === 'small',
|
||||
[s.medium]: props.size === 'medium',
|
||||
[s.large]: props.size === 'large',
|
||||
},
|
||||
props.class,
|
||||
{
|
||||
[s.prefix]: props.prefixIcon,
|
||||
[s.suffix]: hasActions(),
|
||||
[s.invalid]: props.invalid,
|
||||
[s.focused]: focused(),
|
||||
[s.disabled]: props.disabled,
|
||||
[s.error]: props.error || !!computedErrorMessage(),
|
||||
},
|
||||
)}
|
||||
>
|
||||
<Show when={props.prefixIcon}>{props.prefixIcon}</Show>
|
||||
<input
|
||||
ref={(el) => setInputRef(el)}
|
||||
accept={props.accept}
|
||||
autofocus={props.autofocus}
|
||||
class={cn(s.input, props.innerClass, {
|
||||
[s.prefix]: props.prefixIcon,
|
||||
[s.postfix]: hasActions(),
|
||||
})}
|
||||
onChange={handleChange}
|
||||
onInput={(e) => (props.onInput as any)?.(e)}
|
||||
type={props.type}
|
||||
id={props.id}
|
||||
name={props.name}
|
||||
placeholder={props.placeholder}
|
||||
value={props.value as string | number}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={(e) => {
|
||||
props.onBlur?.(e);
|
||||
setFocused(false);
|
||||
}}
|
||||
maxLength={props.maxLength}
|
||||
disabled={props.disabled}
|
||||
autocomplete={props.autocomplete}
|
||||
/>
|
||||
<Show when={hasActions()}>
|
||||
<div class={s.actions}>
|
||||
<Show when={showClearButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class={s.clearButton}
|
||||
aria-label={intl.getMessage('aria_clear_input')}
|
||||
data-testid="input-clear-button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={handleClear}
|
||||
>
|
||||
<Icon icon="cross" />
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={props.suffixIcon}>
|
||||
<div class={s.suffixIcon}>{props.suffixIcon}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={computedErrorMessage()}>
|
||||
<div class={s.inputError}>{computedErrorMessage()}</div>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createSignal, Show } from 'solid-js';
|
||||
|
||||
import { Input } from 'panel/common/controls/Input/index';
|
||||
import { Icon } from 'panel/common/ui/Icon';
|
||||
|
||||
import styles from './Input.module.pcss';
|
||||
|
||||
type Props = Omit<Parameters<typeof Input>[0], 'type' | 'suffixIcon' | 'onChange' | 'value'> & {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
ref?: HTMLInputElement | ((el: HTMLInputElement) => void);
|
||||
};
|
||||
|
||||
export const PasswordInput = (props: Props) => {
|
||||
const [isPasswordVisible, setIsPasswordVisible] = createSignal(false);
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
ref={props.ref}
|
||||
value={props.value}
|
||||
onChange={(e: Event) => props.onChange((e.target as HTMLInputElement).value)}
|
||||
type={isPasswordVisible() ? 'text' : 'password'}
|
||||
suffixIcon={
|
||||
<div class={styles.inputSuffix}>
|
||||
<Show when={!!props.value}>
|
||||
<button
|
||||
class={styles.inputIconButton}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
onMouseDown={(e: MouseEvent) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
setIsPasswordVisible(false);
|
||||
props.onChange('');
|
||||
}}
|
||||
>
|
||||
<Icon icon="cross" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
class={styles.inputIconButton}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
onMouseDown={(e: MouseEvent) => e.preventDefault()}
|
||||
onClick={() => setIsPasswordVisible((v) => !v)}
|
||||
>
|
||||
<Icon icon={(isPasswordVisible() ? 'eye_open' : 'eye_close') as any} />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { Input } from './Input';
|
||||
@@ -0,0 +1,56 @@
|
||||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.radio {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 8px 0;
|
||||
cursor: pointer;
|
||||
|
||||
&.center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&.start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&.end {
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.text {
|
||||
max-width: 250px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: 2px;
|
||||
color: var(--default-description-text);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--default-gray-icons);
|
||||
}
|
||||
|
||||
.active {
|
||||
transition: none;
|
||||
color: var(--default-product-icon);
|
||||
}
|
||||
|
||||
.input:disabled + .handler .icon {
|
||||
color: var(--disabled-gray-icons);
|
||||
}
|
||||
|
||||
.input:disabled:checked + .handler .icon {
|
||||
color: var(--disabled-main-button);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { type JSX, Show, For } from 'solid-js';
|
||||
import cn from 'clsx';
|
||||
|
||||
import { Icon } from 'panel/common/ui/Icon';
|
||||
import theme from 'panel/lib/theme';
|
||||
import s from './Radio.module.pcss';
|
||||
|
||||
type Props<T = string | number | boolean> = {
|
||||
class?: string;
|
||||
wrapClass?: string;
|
||||
disabled?: boolean;
|
||||
handleChange: (e: T) => void;
|
||||
value: T;
|
||||
options: { text: string; value: T; description?: JSX.Element }[];
|
||||
name?: string;
|
||||
textClass?: string;
|
||||
verticalAlign?: 'center' | 'start' | 'end';
|
||||
ref?: HTMLDivElement | ((el: HTMLDivElement) => void);
|
||||
};
|
||||
|
||||
export const Radio = <T extends number | string | boolean = string>(props: Props<T>) => {
|
||||
const setRef = (el: HTMLDivElement) => {
|
||||
if (typeof props.ref === 'function') {
|
||||
props.ref(el);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={(el) => setRef(el)} class={cn(s.wrap, props.wrapClass)}>
|
||||
<For each={props.options}>
|
||||
{(o) => (
|
||||
<label
|
||||
for={props.name ? `${props.name}-${o.value}` : String(o.value)}
|
||||
class={cn(
|
||||
s.radio,
|
||||
props.class,
|
||||
props.verticalAlign && s[props.verticalAlign],
|
||||
)}
|
||||
>
|
||||
<input
|
||||
id={props.name ? `${props.name}-${o.value}` : String(o.value)}
|
||||
type="radio"
|
||||
class={s.input}
|
||||
name={props.name}
|
||||
onChange={() => props.handleChange(o.value)}
|
||||
checked={props.value === o.value}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
<div class={s.handler}>
|
||||
<Icon
|
||||
icon={(props.value === o.value ? 'radio_on' : 'radio_off') as any}
|
||||
class={cn(s.icon, { [s.active]: props.value === o.value })}
|
||||
/>
|
||||
</div>
|
||||
<div class={cn(s.text, props.textClass)}>
|
||||
<div>{o.text}</div>
|
||||
<Show when={o.description}>
|
||||
<div class={cn(theme.text.t4, s.description)}>{o.description}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { Radio } from './Radio';
|
||||
@@ -0,0 +1,25 @@
|
||||
.menuList {
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
max-height: 340px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: var(--default-gray-icons);
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.selectIconContainer {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 8px;
|
||||
color: var(--default-gray-icons);
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
.solid-select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&.solid-select-small {
|
||||
width: 68px;
|
||||
|
||||
[data-scope='select'][data-part='control'],
|
||||
[data-scope='combobox'][data-part='control'] {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.solid-select-medium {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
&.solid-select-big {
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
width: 288px;
|
||||
}
|
||||
}
|
||||
|
||||
&.solid-select--menu-right {
|
||||
[data-scope='select'][data-part='content'],
|
||||
[data-scope='combobox'][data-part='content'] {
|
||||
@media (min-width: 1024px) {
|
||||
/* Right-anchor the dropdown so wide menus expand leftward and stay on screen. */
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.solid-select-responsive {
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.solid-select--menu-small {
|
||||
[data-scope='select'][data-part='content'],
|
||||
[data-scope='combobox'][data-part='content'] {
|
||||
min-width: 137px;
|
||||
}
|
||||
}
|
||||
|
||||
&.solid-select--menu-medium {
|
||||
[data-scope='select'][data-part='content'],
|
||||
[data-scope='combobox'][data-part='content'] {
|
||||
min-width: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
&.solid-select--menu-big {
|
||||
[data-scope='select'][data-part='content'],
|
||||
[data-scope='combobox'][data-part='content'] {
|
||||
min-width: 284px;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
min-width: 344px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.solid-select--borderless {
|
||||
[data-scope='select'][data-part='control'],
|
||||
[data-scope='combobox'][data-part='control'] {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.solid-select--adaptive-height {
|
||||
[data-scope='select'][data-part='control'],
|
||||
[data-scope='combobox'][data-part='control'] {
|
||||
height: 100%;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
[data-scope='select'][data-part='value-text'],
|
||||
[data-scope='combobox'][data-part='value-text'] {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.solid-select--height-small {
|
||||
[data-scope='select'][data-part='control'],
|
||||
[data-scope='combobox'][data-part='control'] {
|
||||
min-height: 40px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&.solid-select--height-medium {
|
||||
[data-scope='select'][data-part='control'],
|
||||
[data-scope='combobox'][data-part='control'] {
|
||||
min-height: 40px;
|
||||
font-size: 16px;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
min-height: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.solid-select--height-big {
|
||||
[data-scope='select'][data-part='control'],
|
||||
[data-scope='combobox'][data-part='control'] {
|
||||
min-height: 56px;
|
||||
font-size: 16px;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
min-height: 56px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.solid-select--height-big-mobile {
|
||||
[data-scope='select'][data-part='control'],
|
||||
[data-scope='combobox'][data-part='control'] {
|
||||
height: 56px;
|
||||
font-size: 16px;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.solid-select--dropdown {
|
||||
[data-scope='select'][data-part='content'],
|
||||
[data-scope='combobox'][data-part='content'] {
|
||||
position: relative;
|
||||
top: 0;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
[data-scope='select'][data-part='control'],
|
||||
[data-scope='combobox'][data-part='control'] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
[data-scope='select'][data-part='content'],
|
||||
[data-scope='combobox'][data-part='content'] {
|
||||
color: var(--default-main-text);
|
||||
padding: 0;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: 8px 8px 24px 0 rgba(0, 0, 0, 0.1);
|
||||
background-color: var(--default-dropdown-menu-background);
|
||||
z-index: 3;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
left: initial;
|
||||
right: initial;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
[data-scope='select'][data-part='control'],
|
||||
[data-scope='combobox'][data-part='control'] {
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--default-item-divider);
|
||||
box-shadow: none;
|
||||
border-radius: 8px;
|
||||
transition: border var(--t2);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
background-color: var(--default-page-background);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
border-color: var(--hovered-main-button);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
background-color: var(--pressed-page-background);
|
||||
cursor: default;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
border-color: var(--default-item-divider);
|
||||
}
|
||||
|
||||
[data-scope='select'][data-part='value-text'],
|
||||
[data-scope='combobox'][data-part='value-text'] {
|
||||
color: var(--default-description-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-scope='select'][data-part='trigger'] {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding-left: 16px;
|
||||
overflow: hidden;
|
||||
min-height: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--default-main-text);
|
||||
font-size: inherit;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-scope='combobox'][data-part='trigger'] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--default-gray-icons);
|
||||
transition: transform var(--t2);
|
||||
|
||||
svg {
|
||||
transform-origin: center center;
|
||||
transition: transform var(--t2);
|
||||
}
|
||||
|
||||
&[data-state='open'] svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
[data-scope='select'][data-part='value-text'],
|
||||
[data-scope='combobox'][data-part='value-text'] {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--default-main-text);
|
||||
|
||||
&:empty {
|
||||
}
|
||||
}
|
||||
|
||||
[data-scope='select'][data-part='clear-trigger'],
|
||||
[data-scope='combobox'][data-part='clear-trigger'] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--default-gray-icons);
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-scope='select'][data-part='indicator'] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding-right: 12px;
|
||||
color: var(--default-gray-icons);
|
||||
transition: transform var(--t2);
|
||||
|
||||
svg {
|
||||
transform-origin: center center;
|
||||
transition: transform var(--t2);
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: var(--default-gray-icons);
|
||||
}
|
||||
|
||||
&[data-state='open'] svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
[data-scope='combobox'][data-part='input'] {
|
||||
flex: 1;
|
||||
min-width: 50px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: inherit;
|
||||
color: var(--default-main-text);
|
||||
padding-left: 16px;
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--default-description-text);
|
||||
}
|
||||
}
|
||||
|
||||
[data-scope='select'][data-part='item'],
|
||||
[data-scope='combobox'][data-part='item'] {
|
||||
color: var(--default-main-text);
|
||||
transition: all var(--t2);
|
||||
cursor: pointer;
|
||||
padding: 16px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
&[data-highlighted] {
|
||||
background-color: var(--hovered-dropdown-menu-background);
|
||||
}
|
||||
|
||||
&[data-state='checked'] {
|
||||
background-color: var(--pressed-dropdown-menu-background);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
color: var(--disabled-main-text);
|
||||
cursor: default;
|
||||
|
||||
&[data-highlighted] {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-scope='select'][data-part='item-text'],
|
||||
[data-scope='combobox'][data-part='item-text'] {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
[data-scope='select'][data-part='item-indicator'],
|
||||
[data-scope='combobox'][data-part='item-indicator'] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--default-gray-icons);
|
||||
|
||||
&[data-state='checked'] {
|
||||
color: var(--default-product-icon);
|
||||
}
|
||||
}
|
||||
|
||||
[data-scope='select'][data-part='item-group'],
|
||||
[data-scope='combobox'][data-part='item-group'] {
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
max-height: 340px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: var(--default-gray-icons);
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.solid-select-multi-value-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px 12px 8px 0;
|
||||
}
|
||||
|
||||
.solid-select-placeholder {
|
||||
color: var(--default-description-text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
import { type JSX, For, Show, createMemo, createSignal } from 'solid-js';
|
||||
import cn from 'clsx';
|
||||
import {
|
||||
Select as ArkSelect,
|
||||
Combobox as ArkCombobox,
|
||||
createListCollection,
|
||||
useSelectContext,
|
||||
useSelectItemContext,
|
||||
useComboboxItemContext,
|
||||
} from '@ark-ui/solid';
|
||||
import { Icon } from 'panel/common/ui/Icon';
|
||||
import { IOption } from 'panel/lib/helpers/utils';
|
||||
import theme from 'panel/lib/theme';
|
||||
|
||||
import { SelectMultiValue } from './SelectMultiValue';
|
||||
|
||||
import './Select.pcss';
|
||||
import s from './MenuList.module.pcss';
|
||||
|
||||
const SEARCH_ENABLE_LIMIT = 10;
|
||||
|
||||
export type ISelectSize = 'auto' | 'small' | 'medium' | 'big' | 'big-limit' | 'responsive';
|
||||
export type ISelectHeight = 'small' | 'medium' | 'big' | 'big-mobile';
|
||||
export type ISelectMenuSize = 'small' | 'medium' | 'big' | 'large';
|
||||
export type ISelectValue<T, Multi extends boolean> = Multi extends true ? IOption<T>[] : IOption<T>;
|
||||
|
||||
interface SelectProps<
|
||||
T,
|
||||
Multi extends boolean = false,
|
||||
ExtendOption extends Record<any, any> = object,
|
||||
> {
|
||||
size?: ISelectSize;
|
||||
height?: ISelectHeight;
|
||||
menuSize?: ISelectMenuSize;
|
||||
menuPosition?: 'right';
|
||||
mobile?: boolean;
|
||||
isDisabled?: boolean;
|
||||
isMulti?: Multi;
|
||||
menuIsOpen?: boolean;
|
||||
placeholder?: JSX.Element;
|
||||
class?: string;
|
||||
autoFocus?: boolean;
|
||||
low?: boolean;
|
||||
options: (IOption<T> & ExtendOption)[];
|
||||
onChange: (
|
||||
value: Multi extends true ? (IOption<T> & ExtendOption)[] : IOption<T> & ExtendOption,
|
||||
) => void;
|
||||
value?: (IOption<T> & ExtendOption) | (IOption<T> & ExtendOption)[];
|
||||
formatGroupLabel?: (group: any) => string;
|
||||
isSearchable?: boolean;
|
||||
id?: string;
|
||||
inputId?: string;
|
||||
borderless?: boolean;
|
||||
adaptiveHeight?: boolean;
|
||||
lazyList?: boolean;
|
||||
closeMenuOnSelect?: boolean;
|
||||
menuPlacement?: 'top' | 'bottom' | 'auto';
|
||||
isClearable?: boolean;
|
||||
onMenuScrollToBottom?: () => void;
|
||||
isLoading?: boolean;
|
||||
isDropdownSelect?: boolean;
|
||||
onMenuOpen?: () => void;
|
||||
onMenuClose?: () => void;
|
||||
onBlur?: () => void;
|
||||
showIcons?: boolean;
|
||||
showOptionIcon?: boolean;
|
||||
optionTestIdPrefix?: string;
|
||||
/** Optional footer rendered after the scrollable item list inside the dropdown */
|
||||
menuFooter?: JSX.Element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-value display wrapper that reads selected items from Ark UI context.
|
||||
* Used inside Select.Trigger to render pill components.
|
||||
*/
|
||||
const SelectMultiValueDisplay = (props: { placeholder?: string }) => {
|
||||
const selectCtx = useSelectContext();
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={selectCtx().hasSelectedItems}
|
||||
fallback={<span class="solid-select-placeholder">{props.placeholder ?? ''}</span>}
|
||||
>
|
||||
<SelectMultiValue
|
||||
items={selectCtx().selectedItems as any[]}
|
||||
onRemove={(item) => selectCtx().clearValue(String(item.value))}
|
||||
/>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check/dot icon for the item indicator.
|
||||
* Matches client_v2 CustomOption: check when selected, dot when not.
|
||||
* Used inside ArkSelect.Item (non-searchable Select branch).
|
||||
*/
|
||||
const OptionCheckIcon = () => {
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
const itemCtx = useSelectItemContext();
|
||||
const state = itemCtx();
|
||||
return <Icon icon={state.selected ? 'check' : 'dot'} />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check/dot icon for the item indicator in Combobox (searchable) mode.
|
||||
* Same appearance as OptionCheckIcon but uses ComboboxItemContext.
|
||||
*/
|
||||
const ComboboxOptionCheckIcon = () => {
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
const itemCtx = useComboboxItemContext();
|
||||
const state = itemCtx();
|
||||
return <Icon icon={state.selected ? 'check' : 'dot'} />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Individual option item rendered inside the dropdown list.
|
||||
*/
|
||||
const SelectItemContent = <T, ExtendOption extends Record<any, any>>(props: {
|
||||
option: IOption<T> & ExtendOption;
|
||||
isMulti?: boolean;
|
||||
showOptionIcon?: boolean;
|
||||
showIcons?: boolean;
|
||||
}) => {
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
const data = props.option as any;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={props.showOptionIcon !== false}>
|
||||
<ArkSelect.ItemIndicator>
|
||||
<OptionCheckIcon />
|
||||
</ArkSelect.ItemIndicator>
|
||||
</Show>
|
||||
<Show when={props.showIcons && data.icon}>
|
||||
<div class={s.selectIconContainer}>
|
||||
<Icon icon={data.icon} />
|
||||
</div>
|
||||
</Show>
|
||||
<ArkSelect.ItemText>{data.label}</ArkSelect.ItemText>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Select = <
|
||||
T,
|
||||
Multi extends boolean = false,
|
||||
ExtendOption extends Record<any, any> = object,
|
||||
>(
|
||||
props: SelectProps<T, Multi, ExtendOption>,
|
||||
) => {
|
||||
/* ---- Derived state ---- */
|
||||
const selectClass = createMemo(() =>
|
||||
cn(
|
||||
{ 'desktop-select-always': props.mobile === false },
|
||||
{ 'mobile-select-always': props.mobile },
|
||||
'solid-select',
|
||||
{ 'solid-select--multi': props.isMulti },
|
||||
{ 'solid-select-big': props.size === 'big' },
|
||||
{ 'solid-select-medium': props.size === 'medium' },
|
||||
{ 'solid-select-small': props.size === 'small' },
|
||||
{ 'solid-select-responsive': props.size === 'responsive' },
|
||||
{ 'solid-select--big-limit': props.size === 'big-limit' },
|
||||
{ 'solid-select--menu-small': props.menuSize === 'small' },
|
||||
{ 'solid-select--menu-medium': props.menuSize === 'medium' },
|
||||
{ 'solid-select--menu-big': props.menuSize === 'big' },
|
||||
{ 'solid-select--menu-right': props.menuPosition === 'right' },
|
||||
{ 'solid-select--borderless': props.borderless },
|
||||
{ 'solid-select--adaptive-height': props.adaptiveHeight },
|
||||
{ 'solid-select--height-small': props.height === 'small' },
|
||||
{ 'solid-select--height-medium': props.height === 'medium' },
|
||||
{ 'solid-select--height-big': props.height === 'big' },
|
||||
{ 'solid-select--height-big-mobile': props.height === 'big-mobile' },
|
||||
{ 'solid-select--dropdown': props.isDropdownSelect },
|
||||
{ low: props.low },
|
||||
props.class,
|
||||
),
|
||||
);
|
||||
|
||||
// Multi-select always uses the non-searchable Select branch (see "Decisions Made"),
|
||||
// so pills render correctly. Combobox is single-select only.
|
||||
const isSearchable = createMemo(
|
||||
() =>
|
||||
!(props.isMulti ?? false) &&
|
||||
(props.isSearchable ?? props.options.length > SEARCH_ENABLE_LIMIT),
|
||||
);
|
||||
|
||||
const isDisabled = createMemo(() => props.isDisabled ?? false);
|
||||
const isMulti = createMemo(() => props.isMulti ?? false);
|
||||
const isClearable = createMemo(() => props.isClearable ?? false);
|
||||
|
||||
/* ---- Combobox search input tracking ---- */
|
||||
const [searchInput, setSearchInput] = createSignal('');
|
||||
/** DOM ref to the Combobox <input> — used to physically clear it on open. */
|
||||
let inputRef: HTMLInputElement | undefined;
|
||||
|
||||
const filteredOptions = createMemo(() => {
|
||||
const query = searchInput().toLowerCase();
|
||||
if (!query) return props.options;
|
||||
return props.options.filter((opt) => String(opt.label).toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
/* ---- Build collection from filtered options ---- */
|
||||
const collection = createMemo(() =>
|
||||
createListCollection({
|
||||
items: filteredOptions() as any[],
|
||||
itemToString: (item: any) => item.label,
|
||||
itemToValue: (item: any) => String(item.value),
|
||||
isItemDisabled: (item: any) => item.isDisabled ?? item.disabled ?? false,
|
||||
}),
|
||||
);
|
||||
|
||||
/* ---- Compute current value as string[] for Ark UI ---- */
|
||||
const currentValue = createMemo<string[]>(() => {
|
||||
if (!props.value) return [];
|
||||
const arr = Array.isArray(props.value) ? props.value : [props.value];
|
||||
return arr.map((v: any) => String(v.value));
|
||||
});
|
||||
|
||||
/* ---- onChange handler ---- */
|
||||
const handleValueChange = (details: { items: any[] }) => {
|
||||
if (isMulti()) {
|
||||
props.onChange(details.items as any);
|
||||
} else {
|
||||
props.onChange(details.items[0] as any);
|
||||
}
|
||||
};
|
||||
|
||||
/* ---- Scroll to bottom ---- */
|
||||
const handleContentScroll = (e: Event) => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
if (el.scrollHeight - el.scrollTop <= el.clientHeight + 50) {
|
||||
props.onMenuScrollToBottom?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={isSearchable()}
|
||||
fallback={
|
||||
/* ===== Non-searchable mode: Ark UI Select ===== */
|
||||
<div class={selectClass()} id={props.id}>
|
||||
<ArkSelect.Root
|
||||
collection={collection()}
|
||||
value={currentValue()}
|
||||
onValueChange={handleValueChange}
|
||||
multiple={isMulti()}
|
||||
disabled={isDisabled()}
|
||||
closeOnSelect={props.closeMenuOnSelect ?? true}
|
||||
open={props.menuIsOpen}
|
||||
onOpenChange={(details) => {
|
||||
if (details.open) {
|
||||
props.onMenuOpen?.();
|
||||
} else {
|
||||
props.onMenuClose?.();
|
||||
}
|
||||
}}
|
||||
positioning={{
|
||||
placement:
|
||||
props.menuPlacement === 'top'
|
||||
? ('top-start' as const)
|
||||
: ('bottom-start' as const),
|
||||
sameWidth: true,
|
||||
fitViewport: true,
|
||||
flip:
|
||||
props.menuPlacement === 'auto'
|
||||
? true
|
||||
: props.menuPlacement === 'top'
|
||||
? ['top-start', 'bottom-start']
|
||||
: ['bottom-start', 'top-start'],
|
||||
}}
|
||||
>
|
||||
<Show when={!props.isDropdownSelect}>
|
||||
<ArkSelect.Control>
|
||||
<ArkSelect.Trigger>
|
||||
<Show
|
||||
when={!isMulti()}
|
||||
fallback={
|
||||
<SelectMultiValueDisplay
|
||||
placeholder={props.placeholder as string}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ArkSelect.ValueText
|
||||
placeholder={props.placeholder as string}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!isMulti() || !currentValue().length}>
|
||||
<ArkSelect.Indicator>
|
||||
<Icon icon="arrow_bottom" />
|
||||
</ArkSelect.Indicator>
|
||||
</Show>
|
||||
</ArkSelect.Trigger>
|
||||
<Show when={isClearable() && !isMulti()}>
|
||||
<ArkSelect.ClearTrigger>
|
||||
<Icon icon="cross" />
|
||||
</ArkSelect.ClearTrigger>
|
||||
</Show>
|
||||
</ArkSelect.Control>
|
||||
</Show>
|
||||
<ArkSelect.Positioner>
|
||||
<ArkSelect.Content onScroll={handleContentScroll}>
|
||||
<Show
|
||||
when={!props.isLoading}
|
||||
fallback={
|
||||
<div class={theme.select.menuLoaderOverlay}>
|
||||
<Icon icon="loader" class={theme.select.menuLoader} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ArkSelect.ItemGroup>
|
||||
<Show
|
||||
when={props.lazyList}
|
||||
fallback={
|
||||
<For each={props.options}>
|
||||
{(option) => {
|
||||
const data = option as any;
|
||||
return (
|
||||
<ArkSelect.Item
|
||||
item={option as any}
|
||||
data-testid={
|
||||
props.optionTestIdPrefix
|
||||
? `${props.optionTestIdPrefix}-${String(data.value)}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SelectItemContent
|
||||
option={option as any}
|
||||
isMulti={isMulti()}
|
||||
showOptionIcon={
|
||||
props.showOptionIcon
|
||||
}
|
||||
showIcons={props.showIcons}
|
||||
/>
|
||||
</ArkSelect.Item>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
}
|
||||
>
|
||||
<div class={s.menuList}>
|
||||
<For each={props.options}>
|
||||
{(option) => {
|
||||
const data = option as any;
|
||||
return (
|
||||
<ArkSelect.Item
|
||||
item={option as any}
|
||||
data-testid={
|
||||
props.optionTestIdPrefix
|
||||
? `${props.optionTestIdPrefix}-${String(data.value)}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SelectItemContent
|
||||
option={option as any}
|
||||
isMulti={isMulti()}
|
||||
showOptionIcon={
|
||||
props.showOptionIcon
|
||||
}
|
||||
showIcons={props.showIcons}
|
||||
/>
|
||||
</ArkSelect.Item>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</ArkSelect.ItemGroup>
|
||||
<Show when={props.menuFooter}>{props.menuFooter}</Show>
|
||||
</Show>
|
||||
</ArkSelect.Content>
|
||||
</ArkSelect.Positioner>
|
||||
</ArkSelect.Root>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* ===== Searchable (Combobox) mode: Ark UI Combobox ===== */}
|
||||
<div class={selectClass()} id={props.id}>
|
||||
<ArkCombobox.Root
|
||||
collection={collection()}
|
||||
value={currentValue()}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={isDisabled()}
|
||||
closeOnSelect={props.closeMenuOnSelect ?? true}
|
||||
openOnClick
|
||||
autoFocus={props.autoFocus}
|
||||
open={props.menuIsOpen}
|
||||
onInputValueChange={(details) => {
|
||||
// Only track actual user keystrokes — ignore programmatic
|
||||
// changes (item-select, clear-trigger, script, interact-outside)
|
||||
// so searchInput always reflects what the user typed.
|
||||
if (details.reason === 'input-change') {
|
||||
setSearchInput(details.inputValue);
|
||||
}
|
||||
}}
|
||||
onOpenChange={(details) => {
|
||||
if (details.open) {
|
||||
// Reset search filter and physically clear the input
|
||||
// so the user sees a blank field ready for fresh typing
|
||||
// (like react-select).
|
||||
setSearchInput('');
|
||||
if (inputRef) {
|
||||
inputRef.value = '';
|
||||
}
|
||||
props.onMenuOpen?.();
|
||||
} else {
|
||||
props.onMenuClose?.();
|
||||
}
|
||||
}}
|
||||
positioning={{
|
||||
placement:
|
||||
props.menuPlacement === 'top'
|
||||
? ('top-start' as const)
|
||||
: ('bottom-start' as const),
|
||||
sameWidth: true,
|
||||
fitViewport: true,
|
||||
flip:
|
||||
props.menuPlacement === 'auto'
|
||||
? true
|
||||
: props.menuPlacement === 'top'
|
||||
? ['top-start', 'bottom-start']
|
||||
: ['bottom-start', 'top-start'],
|
||||
}}
|
||||
>
|
||||
<Show when={!props.isDropdownSelect}>
|
||||
<ArkCombobox.Control
|
||||
onClick={(e: MouseEvent) => {
|
||||
// Click the inner input when clicking the dead zone
|
||||
// of the Control so openOnClick triggers the menu.
|
||||
if (e.target === e.currentTarget) {
|
||||
const input = (e.currentTarget as HTMLElement).querySelector(
|
||||
'input',
|
||||
);
|
||||
input?.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArkCombobox.Input
|
||||
id={props.inputId}
|
||||
placeholder={props.placeholder as string}
|
||||
ref={(el) => {
|
||||
inputRef = el;
|
||||
}}
|
||||
/>
|
||||
<Show when={isClearable() && currentValue().length > 0}>
|
||||
<ArkCombobox.ClearTrigger>
|
||||
<Icon icon="cross" />
|
||||
</ArkCombobox.ClearTrigger>
|
||||
</Show>
|
||||
<ArkCombobox.Trigger>
|
||||
<Icon icon="arrow_bottom" />
|
||||
</ArkCombobox.Trigger>
|
||||
</ArkCombobox.Control>
|
||||
</Show>
|
||||
<ArkCombobox.Positioner>
|
||||
<ArkCombobox.Content onScroll={handleContentScroll}>
|
||||
<Show
|
||||
when={!props.isLoading}
|
||||
fallback={
|
||||
<div class={theme.select.menuLoaderOverlay}>
|
||||
<Icon icon="loader" class={theme.select.menuLoader} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ArkCombobox.ItemGroup>
|
||||
<Show
|
||||
when={props.lazyList}
|
||||
fallback={
|
||||
<For each={filteredOptions()}>
|
||||
{(option) => {
|
||||
const data = option as any;
|
||||
return (
|
||||
<ArkCombobox.Item
|
||||
item={option as any}
|
||||
data-testid={
|
||||
props.optionTestIdPrefix
|
||||
? `${props.optionTestIdPrefix}-${String(data.value)}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={
|
||||
props.showOptionIcon !== false
|
||||
}
|
||||
>
|
||||
<ArkCombobox.ItemIndicator>
|
||||
<ComboboxOptionCheckIcon />
|
||||
</ArkCombobox.ItemIndicator>
|
||||
</Show>
|
||||
<Show
|
||||
when={props.showIcons && data.icon}
|
||||
>
|
||||
<div class={s.selectIconContainer}>
|
||||
<Icon icon={data.icon} />
|
||||
</div>
|
||||
</Show>
|
||||
<ArkCombobox.ItemText>
|
||||
{data.label}
|
||||
</ArkCombobox.ItemText>
|
||||
</ArkCombobox.Item>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
}
|
||||
>
|
||||
<div class={s.menuList}>
|
||||
<For each={filteredOptions()}>
|
||||
{(option) => {
|
||||
const data = option as any;
|
||||
return (
|
||||
<ArkCombobox.Item
|
||||
item={option as any}
|
||||
data-testid={
|
||||
props.optionTestIdPrefix
|
||||
? `${props.optionTestIdPrefix}-${String(data.value)}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={
|
||||
props.showOptionIcon !== false
|
||||
}
|
||||
>
|
||||
<ArkCombobox.ItemIndicator>
|
||||
<ComboboxOptionCheckIcon />
|
||||
</ArkCombobox.ItemIndicator>
|
||||
</Show>
|
||||
<Show
|
||||
when={props.showIcons && data.icon}
|
||||
>
|
||||
<div class={s.selectIconContainer}>
|
||||
<Icon icon={data.icon} />
|
||||
</div>
|
||||
</Show>
|
||||
<ArkCombobox.ItemText>
|
||||
{data.label}
|
||||
</ArkCombobox.ItemText>
|
||||
</ArkCombobox.Item>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={props.menuFooter}>{props.menuFooter}</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</ArkCombobox.ItemGroup>
|
||||
</Show>
|
||||
</ArkCombobox.Content>
|
||||
</ArkCombobox.Positioner>
|
||||
</ArkCombobox.Root>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 8px 6px 16px;
|
||||
border: 1px solid var(--default-item-divider);
|
||||
border-radius: 200px;
|
||||
background-color: var(--default-page-background);
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--default-main-text);
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
color: var(--default-gray-icons);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { For } from 'solid-js';
|
||||
import { Icon } from 'panel/common/ui/Icon';
|
||||
import intl from 'panel/common/intl';
|
||||
|
||||
import s from './SelectMultiValue.module.pcss';
|
||||
|
||||
interface SelectMultiValueProps {
|
||||
items: {
|
||||
value: string | null;
|
||||
label: string;
|
||||
[key: string]: any;
|
||||
}[];
|
||||
onRemove: (item: any) => void;
|
||||
}
|
||||
|
||||
export const SelectMultiValue = (props: SelectMultiValueProps) => (
|
||||
<div class="solid-select-multi-value-container">
|
||||
<For each={props.items}>
|
||||
{(item) => (
|
||||
<div class={s.pill}>
|
||||
<span class={s.label}>{item.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
class={s.removeBtn}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onRemove(item);
|
||||
}}
|
||||
aria-label={intl.getMessage('remove_tag', {
|
||||
value: item.label,
|
||||
})}
|
||||
>
|
||||
<Icon icon="cross" color="gray" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { ISelectSize, ISelectValue } from './Select';
|
||||
export { Select } from './Select';
|
||||
@@ -0,0 +1,88 @@
|
||||
.switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
gap: 8px;
|
||||
|
||||
&.disabled {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 16px;
|
||||
font-weight: var(--weight-medium);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.handler {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
border-radius: 24px;
|
||||
background-color: var(--fills-switch-off-default);
|
||||
transition: background-color var(--t2);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--fills-switch-off-hovered);
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 16px;
|
||||
background-color: var(--fills-switch-knob);
|
||||
transition:
|
||||
transform var(--t2),
|
||||
background-color var(--t2);
|
||||
}
|
||||
}
|
||||
|
||||
.input:checked + .handler {
|
||||
background-color: var(--fills-switch-on-default);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--fills-switch-on-hovered);
|
||||
}
|
||||
|
||||
&:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
|
||||
.input:disabled + .handler {
|
||||
cursor: default;
|
||||
background-color: var(--fills-switch-off-disabled);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--fills-switch-off-disabled);
|
||||
}
|
||||
|
||||
&:before {
|
||||
color: var(--fills-switch-knob-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
.input:checked:disabled + .handler {
|
||||
background-color: var(--fills-switch-on-disabled);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--fills-switch-on-disabled);
|
||||
}
|
||||
|
||||
&:before {
|
||||
color: var(--fills-switch-knob-disabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { type JSX, Show } from 'solid-js';
|
||||
import cn from 'clsx';
|
||||
|
||||
import s from './Switch.module.pcss';
|
||||
|
||||
type Props = Omit<JSX.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'type'> & {
|
||||
id: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
labelClass?: string;
|
||||
class?: string;
|
||||
wrapperClass?: string;
|
||||
onChange: (e: Event) => void;
|
||||
children?: JSX.Element;
|
||||
ref?: HTMLInputElement | ((el: HTMLInputElement) => void);
|
||||
};
|
||||
|
||||
export const Switch = (props: Props) => {
|
||||
const setRef = (el: HTMLInputElement) => {
|
||||
if (typeof props.ref === 'function') {
|
||||
props.ref(el);
|
||||
}
|
||||
};
|
||||
|
||||
const switchControls = (
|
||||
<>
|
||||
<input
|
||||
id={props.id}
|
||||
type="checkbox"
|
||||
class={s.input}
|
||||
onChange={(e) => props.onChange?.(e)}
|
||||
checked={props.checked}
|
||||
disabled={props.disabled}
|
||||
ref={(el) => setRef(el)}
|
||||
/>
|
||||
<div class={s.handler} />
|
||||
<Show when={props.children}>
|
||||
<div class={cn(s.label, props.labelClass)}>{props.children}</div>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<label for={props.id} class={cn(s.switch, props.class, { [s.disabled]: props.disabled })}>
|
||||
<Show when={props.wrapperClass} fallback={switchControls}>
|
||||
<div class={props.wrapperClass}>{switchControls}</div>
|
||||
</Show>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { Switch } from './Switch';
|
||||
@@ -0,0 +1,51 @@
|
||||
import { type JSX, Show } from 'solid-js';
|
||||
import cn from 'clsx';
|
||||
|
||||
import s from './styles.module.pcss';
|
||||
|
||||
type Props = JSX.TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||||
label?: JSX.Element;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
errorMessage?: string;
|
||||
ref?: HTMLTextAreaElement | ((el: HTMLTextAreaElement) => void);
|
||||
};
|
||||
|
||||
export const Textarea = (props: Props) => {
|
||||
const setRef = (el: HTMLTextAreaElement) => {
|
||||
if (typeof props.ref === 'function') {
|
||||
props.ref(el);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={s.textareaWrapper}>
|
||||
<Show when={props.label}>
|
||||
<label class={s.label} for={props.id}>
|
||||
{props.label}
|
||||
</label>
|
||||
</Show>
|
||||
<textarea
|
||||
class={cn(
|
||||
s.textarea,
|
||||
props.size && s[props.size],
|
||||
{ [s.error]: !!props.errorMessage },
|
||||
props.class,
|
||||
)}
|
||||
id={props.id}
|
||||
name={props.name}
|
||||
placeholder={props.placeholder}
|
||||
value={props.value as string}
|
||||
cols={props.cols}
|
||||
rows={props.rows}
|
||||
onChange={(e) => (props.onChange as any)?.(e)}
|
||||
wrap={props.wrap}
|
||||
maxLength={props.maxLength}
|
||||
disabled={props.disabled}
|
||||
ref={(el) => setRef(el)}
|
||||
/>
|
||||
<Show when={props.errorMessage}>
|
||||
<div class={s.errorMessage}>{props.errorMessage}</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { Textarea } from './Textarea';
|
||||
@@ -0,0 +1,68 @@
|
||||
.textareaWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
max-width: 100%;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--default-item-divider);
|
||||
outline: none;
|
||||
color: var(--default-main-text);
|
||||
background-color: var(--default-page-background);
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
border var(--t2),
|
||||
background-color var(--t2);
|
||||
resize: none;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--pressed-link);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--default-placeholder);
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-color: var(--default-error-icon);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--pressed-page-background);
|
||||
color: var(--default-description-text);
|
||||
cursor: default;
|
||||
|
||||
&:-webkit-autofill,
|
||||
&:-webkit-autofill:hover,
|
||||
&:-webkit-autofill:focus {
|
||||
box-shadow: 0 0 0 100px var(--pressed-page-background) inset;
|
||||
}
|
||||
}
|
||||
|
||||
&.medium {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
&.large {
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--default-description-text);
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--default-error-icon);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
|
||||
import { I18nInterface, translate } from '@adguard/translate';
|
||||
import { BASE_LOCALE } from 'panel/helpers/twosky';
|
||||
|
||||
import en from 'panel/__locales/en.json';
|
||||
import de from 'panel/__locales/de.json';
|
||||
import es from 'panel/__locales/es.json';
|
||||
import fr from 'panel/__locales/fr.json';
|
||||
import it from 'panel/__locales/it.json';
|
||||
import ja from 'panel/__locales/ja.json';
|
||||
import ko from 'panel/__locales/ko.json';
|
||||
import ptBr from 'panel/__locales/pt-br.json';
|
||||
import ptPt from 'panel/__locales/pt-pt.json';
|
||||
import ru from 'panel/__locales/ru.json';
|
||||
import zhCn from 'panel/__locales/zh-cn.json';
|
||||
import zhTw from 'panel/__locales/zh-tw.json';
|
||||
import { LOCAL_STORAGE_KEYS, LocalStorageHelper } from 'panel/helpers/localStorageHelper';
|
||||
|
||||
export type LocalesType = ReturnType<I18nInterface['getUILanguage']>;
|
||||
|
||||
type LocalesTypes = Partial<Record<LocalesType, Record<string, string>>>;
|
||||
|
||||
const LOCALES = {
|
||||
en,
|
||||
de,
|
||||
es,
|
||||
fr,
|
||||
it,
|
||||
ja,
|
||||
ko,
|
||||
ru,
|
||||
'pt-br': ptBr,
|
||||
'pt-pt': ptPt,
|
||||
'zh-cn': zhCn,
|
||||
'zh-tw': zhTw,
|
||||
};
|
||||
|
||||
const messages: LocalesTypes = LOCALES;
|
||||
|
||||
const resolveLanguage = (lng: string): LocalesType => {
|
||||
const l = lng.toLowerCase();
|
||||
|
||||
if (messages[l as LocalesType]) {
|
||||
return l as LocalesType;
|
||||
}
|
||||
|
||||
// Chinese locales
|
||||
if (l.startsWith('zh')) {
|
||||
if (l.includes('tw') || l.includes('hk') || l.includes('mo')) {
|
||||
return 'zh-tw' as LocalesType;
|
||||
}
|
||||
return 'zh-cn' as LocalesType;
|
||||
}
|
||||
|
||||
// Portuguese locales
|
||||
if (l.startsWith('pt')) {
|
||||
if (l.includes('br')) {
|
||||
return 'pt-br' as LocalesType;
|
||||
}
|
||||
return 'pt-pt' as LocalesType;
|
||||
}
|
||||
|
||||
// Try base language (e.g., en-us -> en)
|
||||
const base = l.split('-')[0] as LocalesType;
|
||||
if (messages[base]) {
|
||||
return base;
|
||||
}
|
||||
|
||||
return BASE_LOCALE as LocalesType;
|
||||
};
|
||||
|
||||
export const i18n = (lang: LocalesType) => {
|
||||
const resolved = resolveLanguage(lang);
|
||||
return {
|
||||
getMessage: (key: string) => messages[resolved]?.[key] || '',
|
||||
getUILanguage: () => resolved,
|
||||
getBaseMessage: (key: string) => messages.en![key] || key,
|
||||
getBaseUILanguage: () => BASE_LOCALE as LocalesType,
|
||||
};
|
||||
};
|
||||
|
||||
const detectedLanguage = ((typeof window !== 'undefined' &&
|
||||
typeof localStorage !== 'undefined' &&
|
||||
LocalStorageHelper.getItem(LOCAL_STORAGE_KEYS.LANGUAGE)) ||
|
||||
(typeof navigator !== 'undefined' && (navigator.language as string)) ||
|
||||
BASE_LOCALE) as LocalesType;
|
||||
|
||||
const initialLanguage: LocalesType = resolveLanguage(detectedLanguage);
|
||||
|
||||
// Solid-reactive language signal
|
||||
const [lang, setLang] = createSignal<LocalesType>(initialLanguage);
|
||||
|
||||
/**
|
||||
* Creates default value functions for common HTML tags.
|
||||
* Mirrors createReactTranslator's createDefaultValues(), but returns real
|
||||
* DOM nodes via document.createElement instead of React element descriptors.
|
||||
*
|
||||
* Why document.createElement and not solid-js/h or <Dynamic>:
|
||||
* @adguard/translate is built around the React/Preact model where
|
||||
* createElement() returns a cheap descriptor object the framework reconciles
|
||||
* later. SolidJS has no equivalent — its JSX compiles to fine-grained template
|
||||
* cloning, and h()/Dynamic need a reactive owner. A plain DOM node has no
|
||||
* ownership entanglement: SolidJS just inserts and removes it
|
||||
* (normalizeIncomingArray keeps anything with a `.nodeType` as-is), which is
|
||||
* the most robust option for these static styling tags.
|
||||
*
|
||||
* `<br>` is intentionally NOT provided: the parser rejects `<br/>`/`<br>` as
|
||||
* unbalanced tags, and void tags must be plain strings (not functions), so a
|
||||
* `br` handler could never be invoked anyway.
|
||||
*
|
||||
* These are fallbacks for when a component doesn't pass a per-call handler.
|
||||
* The library merges user-supplied values over these, with user values winning.
|
||||
* For interactive tags (<a>, <button>), always pass a per-call JSX handler so
|
||||
* events go through SolidJS's delegation system.
|
||||
*/
|
||||
export const createSolidDefaultValues = () => ({
|
||||
// Inline elements
|
||||
strong: (children: string) => {
|
||||
const el = document.createElement('strong');
|
||||
el.textContent = children;
|
||||
return el;
|
||||
},
|
||||
code: (children: string) => {
|
||||
const el = document.createElement('code');
|
||||
el.textContent = children;
|
||||
return el;
|
||||
},
|
||||
b: (children: string) => {
|
||||
const el = document.createElement('b');
|
||||
el.textContent = children;
|
||||
return el;
|
||||
},
|
||||
i: (children: string) => {
|
||||
const el = document.createElement('i');
|
||||
el.textContent = children;
|
||||
return el;
|
||||
},
|
||||
em: (children: string) => {
|
||||
const el = document.createElement('em');
|
||||
el.textContent = children;
|
||||
return el;
|
||||
},
|
||||
|
||||
// Block elements
|
||||
p: (children: string) => {
|
||||
const el = document.createElement('p');
|
||||
el.textContent = children;
|
||||
return el;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Custom message constructor for SolidJS.
|
||||
* When all formatted elements are strings, join them (backward compatible).
|
||||
* When JSX elements are present, return the array — SolidJS renders
|
||||
* arrays of strings + JSX elements natively.
|
||||
*/
|
||||
export const solidMessageConstructor = (formatted: unknown[]): string | unknown[] => {
|
||||
if (formatted.every((child) => typeof child === 'string')) {
|
||||
return (formatted as string[]).join('');
|
||||
}
|
||||
return formatted;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a @adguard/translate translator configured for SolidJS.
|
||||
*
|
||||
* Hand-rolled `createSolidTranslator` adapter: the library ships React/Preact
|
||||
* factories but no Solid one, so we reuse the generic createTranslator and
|
||||
* inject:
|
||||
* - solidMessageConstructor: keeps arrays of nodes intact (the default
|
||||
* join('') would stringify DOM/JSX nodes to "[object Object]");
|
||||
* - createSolidDefaultValues(): renders styling tags as real DOM nodes.
|
||||
*
|
||||
* See createSolidDefaultValues for why document.createElement is used.
|
||||
*/
|
||||
const createSolidTranslator = (i18nInstance: ReturnType<typeof i18n>) =>
|
||||
translate.createTranslator<any>(
|
||||
i18nInstance,
|
||||
solidMessageConstructor,
|
||||
createSolidDefaultValues(),
|
||||
);
|
||||
|
||||
let translator = createSolidTranslator(i18n(initialLanguage));
|
||||
|
||||
const intl = {
|
||||
getMessage: (key: string, values?: any) => {
|
||||
lang(); // track the language signal for reactivity
|
||||
try {
|
||||
return translator.getMessage(key, values);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[i18n] Missing placeholder value for key:', key, e);
|
||||
return key;
|
||||
}
|
||||
},
|
||||
|
||||
getPlural: (key: string, number: number, values?: any) => {
|
||||
lang(); // track the language signal for reactivity
|
||||
try {
|
||||
return translator.getPlural(key, number, values);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[i18n] Missing placeholder value for plural key:', key, e);
|
||||
return key;
|
||||
}
|
||||
},
|
||||
|
||||
getUILanguage: () => lang(),
|
||||
|
||||
getBaseMessage: (key: string) => {
|
||||
return messages.en?.[key] || key;
|
||||
},
|
||||
|
||||
getBaseUILanguage: () => BASE_LOCALE as LocalesType,
|
||||
|
||||
changeLanguage: (newLang: LocalesType) => {
|
||||
const resolved = resolveLanguage(newLang);
|
||||
translator = createSolidTranslator(i18n(resolved));
|
||||
setLang(resolved);
|
||||
},
|
||||
|
||||
get translator() {
|
||||
return translator;
|
||||
},
|
||||
};
|
||||
|
||||
export default intl;
|
||||
@@ -0,0 +1,68 @@
|
||||
:root {
|
||||
--product-primary-0: #f7fbf8;
|
||||
--product-primary-10: #d9ecde;
|
||||
--product-primary-20: #bedfc6;
|
||||
--product-primary-30: #a2d0ad;
|
||||
--product-primary-40: #84c193;
|
||||
--product-primary-50: #67b279;
|
||||
--product-primary-60: #5b9f6b;
|
||||
--product-primary-70: #4e8c5d;
|
||||
--product-primary-80: #42794f;
|
||||
--product-primary-90: #366642;
|
||||
--product-primary-100: #22482c;
|
||||
--product-secondary-0: #f5edf5;
|
||||
--product-secondary-10: #dec8e1;
|
||||
--product-secondary-20: #ccaad1;
|
||||
--product-secondary-30: #c096c7;
|
||||
--product-secondary-40: #b586be;
|
||||
--product-secondary-50: #a870b2;
|
||||
--product-secondary-60: #9f61aa;
|
||||
--product-secondary-70: #92549c;
|
||||
--product-secondary-80: #804886;
|
||||
--product-secondary-90: #6d3e72;
|
||||
--product-secondary-100: #502d53;
|
||||
--product-tertiary-0: #defdf2;
|
||||
--product-tertiary-10: #ecedf6;
|
||||
--product-tertiary-20: #d0d4ea;
|
||||
--product-tertiary-30: #b6bbdd;
|
||||
--product-tertiary-40: #9ba2d1;
|
||||
--product-tertiary-50: #7884cb;
|
||||
--product-tertiary-60: #6b76bd;
|
||||
--product-tertiary-70: #5c67ae;
|
||||
--product-tertiary-80: #525b97;
|
||||
--product-tertiary-90: #424979;
|
||||
--product-tertiary-100: #303659;
|
||||
--gray-0: #ffffff;
|
||||
--gray-10: #f6f6f6;
|
||||
--gray-20: #e4e4e4;
|
||||
--gray-30: #d2d2d2;
|
||||
--gray-40: #c0c0c0;
|
||||
--gray-50: #a4a4a4;
|
||||
--gray-60: #7f7f7f;
|
||||
--gray-70: #5b5b5b;
|
||||
--gray-80: #3d3d3d;
|
||||
--gray-90: #1f1f1f;
|
||||
--gray-100: #0a0a0a;
|
||||
--orange-0: #fdf9f2;
|
||||
--orange-10: #f9ecd8;
|
||||
--orange-20: #eecb92;
|
||||
--orange-30: #e5b460;
|
||||
--orange-40: #de9e33;
|
||||
--orange-50: #d58500;
|
||||
--orange-60: #c77901;
|
||||
--orange-70: #b76c01;
|
||||
--orange-80: #a85f01;
|
||||
--orange-90: #985201;
|
||||
--orange-100: #503200;
|
||||
--red-0: #fcece6;
|
||||
--red-10: #f6c2b1;
|
||||
--red-20: #f2a288;
|
||||
--red-30: #ef9071;
|
||||
--red-40: #ec7b55;
|
||||
--red-50: #e9653a;
|
||||
--red-60: #e75727;
|
||||
--red-70: #dc4918;
|
||||
--red-80: #bc3f15;
|
||||
--red-90: #a13612;
|
||||
--red-100: #77280d;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
[data-theme='dark'] {
|
||||
/* Page background */
|
||||
--default-page-background: var(--gray-90);
|
||||
--hovered-page-background: var(--gray-80);
|
||||
--pressed-page-background: var(--gray-70);
|
||||
--page-background-additional: var(--gray-80);
|
||||
--page-background-additional-pressed: var(--gray-70);
|
||||
/* Cards background */
|
||||
--default-cards-background: var(--gray-80);
|
||||
/* Popup background */
|
||||
--default-popup-background: var(--gray-90);
|
||||
/* Footer background */
|
||||
--default-footer-background: var(--gray-80);
|
||||
/* Item divider */
|
||||
--default-item-divider: var(--gray-70);
|
||||
/* Main text */
|
||||
--default-main-text: var(--gray-20);
|
||||
--disabled-main-text: var(--gray-50);
|
||||
/* Description text */
|
||||
--default-description-text: var(--gray-30);
|
||||
/* Forms */
|
||||
--default-labels: var(--gray-30);
|
||||
/* Input */
|
||||
--default-input-background: var(--gray-80);
|
||||
--default-active-input-stroke: var(--gray-50);
|
||||
--default-inactive-input-stroke: var(--gray-70);
|
||||
--default-placeholder: var(--gray-50);
|
||||
--default-input-on-card-background: var(--gray-70);
|
||||
--disabled-input-on-card-background: var(--gray-70);
|
||||
/* Dropdown */
|
||||
--default-dropdown-menu-background: var(--gray-80);
|
||||
--hovered-dropdown-menu-background: var(--gray-70);
|
||||
--pressed-dropdown-menu-background: var(--gray-60);
|
||||
/* Main button */
|
||||
--default-main-button: var(--product-primary-50);
|
||||
--hovered-main-button: var(--product-primary-60);
|
||||
--pressed-main-button: var(--product-primary-70);
|
||||
--disabled-main-button: var(--product-primary-90);
|
||||
--default-primary-button-text: var(--gray-0);
|
||||
--disabled-primary-button-text: var(--product-primary-20);
|
||||
--default-primary-button-icon: var(--gray-0);
|
||||
/* Secondary button */
|
||||
--default-secondary-button: var(--gray-90);
|
||||
--hovered-secondary-button: var(--gray-80);
|
||||
--pressed-secondary-button: var(--gray-70);
|
||||
--disabled-secondary-button: var(--gray-90);
|
||||
--default-secondary-button-stroke: var(--gray-20);
|
||||
--disabled-secondary-button-stroke: var(--gray-50);
|
||||
/* Secondary card button */
|
||||
--default-secondary-card-button: var(--gray-80);
|
||||
--hovered-secondary-card-button: var(--gray-70);
|
||||
--pressed-secondary-card-button: var(--gray-60);
|
||||
--disabled-secondary-card-button: var(--gray-80);
|
||||
/* Danger button */
|
||||
--default-danger-button: var(--red-50);
|
||||
--hovered-danger-button: var(--red-60);
|
||||
--pressed-danger-button: var(--red-70);
|
||||
--disabled-danger-button: var(--red-30);
|
||||
/* Link */
|
||||
--default-link: var(--product-primary-50);
|
||||
--hovered-link: var(--product-primary-60);
|
||||
--pressed-link: var(--product-primary-70);
|
||||
--visited-link: var(--product-primary-30);
|
||||
/* Attention link */
|
||||
--default-attention-link: var(--orange-50);
|
||||
--hovered-attention-link: var(--orange-60);
|
||||
--pressed-attention-link: var(--orange-70);
|
||||
--disabled-attention-link: var(--orange-30);
|
||||
/* Additional link */
|
||||
--default-additional-link: var(--product-secondary-50);
|
||||
/* Error link */
|
||||
--default-error-link: var(--red-50);
|
||||
/* Icons */
|
||||
--default-product-icon: var(--product-primary-50);
|
||||
--default-black-icons: var(--gray-20);
|
||||
--default-gray-icons: var(--gray-40);
|
||||
--disabled-gray-icons: var(--gray-70);
|
||||
--default-error-icon: var(--red-50);
|
||||
--stroke-icons-white-icons-default: var(--gray-0);
|
||||
--stroke-icons-secondary-icon-disabled: var(--product-secondary-100);
|
||||
--stroke-icons-tertiary-icon-disabled: var(--product-tertiary-100);
|
||||
--default-product-secondary-icon-stroke: var(--product-secondary-50);
|
||||
--default-attention-icon-stroke: var(--orange-50);
|
||||
/* Stat */
|
||||
--default-stats-background: var(--gray-70);
|
||||
--default-red-stat: var(--red-50);
|
||||
--default-tertiary-stat: var(--gray-60);
|
||||
/* Modal */
|
||||
--modal-iframe-overlay: #00000075;
|
||||
--modal-overlay: #00000075;
|
||||
/* Notifications */
|
||||
--default-notifications-attention: var(--orange-100);
|
||||
/* Logo key */
|
||||
--default-logo-key-color: var(--gray-10);
|
||||
/* Loaders */
|
||||
--default-loaders-background: var(--gray-70);
|
||||
--default-loaders-background-dark: var(--gray-70);
|
||||
--default-loaders-primary: var(--product-primary-50);
|
||||
/* Toplines */
|
||||
--default-text-toplines-main: var(--gray-0);
|
||||
--disabled-text-toplines-main: var(--gray-20);
|
||||
--default-fills-toplines-adblocker: var(--gray-100);
|
||||
--default-fills-toplines-vpn: var(--gray-80);
|
||||
/* Breadcrumbs */
|
||||
--default-breadcrumbs: var(--gray-50);
|
||||
/* Promo top line */
|
||||
--fills-toplines-topline-background: var(--gray-100);
|
||||
--text-toplines-topline-title: var(--gray-0);
|
||||
--text-toplines-topline-description: var(--gray-0);
|
||||
--text-toplines-topline-button-text-default: var(--gray-0);
|
||||
--fills-toplines-topline-background-image: none;
|
||||
--fills-toplines-topline-button-default: var(--product-primary-50);
|
||||
--fills-toplines-topline-button-hovered: var(--product-primary-60);
|
||||
--fills-toplines-topline-button-pressed: var(--product-primary-70);
|
||||
--stroke-toplines-button-stroke-default: var(--product-primary-50);
|
||||
--stroke-toplines-button-stroke-hovered: var(--product-primary-60);
|
||||
--stroke-toplines-button-stroke-pressed: var(--product-primary-70);
|
||||
--stroke-toplines-close-icon-default: var(--gray-0);
|
||||
--stroke-toplines-close-icon-hovered: var(--gray-10);
|
||||
--stroke-toplines-close-icon-pressed: var(--gray-20);
|
||||
--fills-backgrounds-recent-activity: var(--product-primary-90);
|
||||
/* Switch */
|
||||
--fills-switch-on-default: var(--product-primary-50);
|
||||
--fills-switch-on-hovered: var(--product-primary-60);
|
||||
--fills-switch-on-disabled: var(--product-primary-90);
|
||||
--fills-switch-off-default: var(--gray-60);
|
||||
--fills-switch-off-hovered: var(--gray-70);
|
||||
--fills-switch-off-disabled: var(--gray-70);
|
||||
--fills-switch-knob: var(--gray-0);
|
||||
--fills-switch-knob-disabled: var(--gray-50);
|
||||
/* Logo */
|
||||
--default-logo-color: var(--gray-0);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user