diff --git a/docs/releases/2.1.0.md b/docs/releases/2.1.0.md
new file mode 100644
index 0000000..d3a43ae
--- /dev/null
+++ b/docs/releases/2.1.0.md
@@ -0,0 +1,45 @@
+# NodeFlow 2.1.0
+
+Panel **2.1.0** · Node Agent **2.1.0** · HAProxy renderer **v21**
+
+Настройки HAProxy для ноды и полноценный редактор `haproxy.cfg`. Одна аддитивная миграция (000055). Ноды без новых настроек рендерятся байт в байт как в 2.0.1. Идея и первая версия — @fesevu (#15).
+
+## Новое
+
+- **Настройки HAProxy ноды.** В диалоге настроек ноды (шестерёнка), строкой под переключателем «Логи соединений HAProxy»: `maxconn`, `nbthread`, `timeout connect`, `timeout client`, `timeout server`. Пустое поле — значение по умолчанию. Настройки действуют на все маршруты ноды; изменение публикует новую ревизию, Agent применяет её reload без разрыва соединений. Хранятся в `nodes.metadata.haproxy_settings`. Обновлять Agent не нужно.
+- **Редактор конфигурации HAProxy.** Кнопка «Конфигурация HAProxy» на странице ноды открывает `/nodes/:id/haproxy`:
+ - редактор Monaco (поставляется с Panel, грузится отдельно только на этой странице), подсветка синтаксиса HAProxy, миникарта, автодополнение;
+ - подсказки на русском при наведении: секции, ~120 директив, параметры `server`/`bind`, алгоритмы `balance`, fetch-функции;
+ - предупреждения об опасных местах; имена `nf_be_*`/`nf_srv_*` помечены «не переименовывать» — от них зависят статистика и квоты;
+ - строка состояния: время и «Конфиг валиден» или «N ошибок, M предупреждений» (клик — к первой проблеме), список портов прослушивания;
+ - кнопки: «Сохранить» (Ctrl+S), «Всё равно сохранить» (только при ошибках линтера), «Форматировать», «Применить на ноду» (сохраняет и применяет, перед применением — diff с применённым конфигом; при ошибках линтера — дополнительное подтверждение), «История», «Загрузить из маршрутов», «Вернуться к сборке из UI».
+- **Проверка конфига.** Линтер в браузере на каждое изменение и `POST /api/v1/nodes/{id}/config-lint` на Panel (задержка 250 мс). Проверка статическая (в образе Panel нет `haproxy`), списки ключевых слов сверены с исходниками HAProxy 2.8, 3.0 и 3.2. Находит: неизвестные секции, директивы, `option`, параметры `server`/`bind`, виды `timeout`; директиву не в той секции; лишние `,`/`;` (например, `option tcplog,`); повторяющиеся имена proxy и серверов; `use_backend`/`default_backend` на несуществующий backend; неверные порты, адреса, длительности, значения `mode`/`balance`/`hash-type`/`log`; несбалансированные `.if`/`.endif`; конфиг больше 512 КиБ. Окончательная проверка — по-прежнему `haproxy -c` на ноде перед применением.
+- **Ручное сохранение проверяется на Panel.** С ошибками линтера API отвечает 422 `config_lint_failed` со списком проблем; `"force": true` сохраняет всё равно. Первая строка `# Generated by NodeFlow. Do not edit.` заменяется на `# Edited manually in NodeFlow advanced editor.`, в конец добавляется перевод строки (иначе `haproxy -c` падает с `Missing LF on last line`).
+- **Текст ошибки `haproxy -c`.** Agent 2.1.0 при неудачной проверке отправляет строки `[ALERT]`/`[WARNING]` (путь временного файла заменён на `haproxy.cfg`, без цветовых кодов и строк с `password`/`secret`, до 1500 байт). Panel отдаёт их в `config-state.last_error_detail`, редактор показывает под ошибкой со ссылками на строки. Старые Agent сообщают только `validation_failed`.
+- **«Добавить ноду»:** переключатель «Логи соединений HAProxy» сразу в диалоге, значение передаётся в установку ноды.
+
+## Исправления
+
+- **Ручной конфиг при политике файрвола `apply` ломал heartbeat Agent** — каждый запрос завершался 500 `desired firewall listener plan is incomplete`. Теперь план файрвола берёт порты из `listener_tcp_ports` ручной ревизии (вычисляются по строкам `bind`). Для старых ручных ревизий без этого поля текущие порты сохраняются, ничего не закрывается.
+
+## Обновление с 2.0.1
+
+1. **Panel.** Повторно запустите установщик:
+
+ ```bash
+ curl -fsSL https://raw.githubusercontent.com/NodeFlow-dev/nodeflow/main/install.sh | sudo sh
+ ```
+
+ Вручную: в `/opt/nodeflow/.env` укажите `NODEFLOW_VERSION=2.1.0` и выполните `docker compose pull && docker compose up -d`. Миграция 000055 только добавляет столбец (`ADD COLUMN … NULL`); откат — вернуть `NODEFLOW_VERSION=2.0.1`, столбец можно оставить.
+2. **Node Agent.** «Настройки → Node Agent» → «Обновить до 2.1.0». Нужно только для текста ошибки `haproxy -c` в редакторе; настройки HAProxy ноды и всё остальное работают с Agent 2.0.x.
+
+Учтите: пока на ноде назначен ручной конфиг, маршруты этой ноды из UI не публикуются (409 `advanced_config_active`) — до «Вернуться к сборке из UI».
+
+Обновляетесь с 1.0.x — сначала прочитайте [заметки к релизу 2.0.0](2.0.0.md): там однократный drop-in systemd для pipe-буферов ядра.
+
+## Файлы релиза
+
+- `NodeFlow-Panel-2.1.0-Agent-2.1.0-install-kit.tar.gz` — install kit.
+- `nodeflow-node-agent-2.1.0-linux-amd64`, `nodeflow-node-agent-2.1.0-linux-arm64` — бинарники Node Agent.
+
+Panel поставляется образом `ghcr.io/nodeflow-dev/nodeflow-panel:2.1.0` (linux/amd64 и linux/arm64). SHA-256 каждого файла — поле `digest` в API релиза; `install.sh` и `install-node.sh` сверяют его сами.
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index a5d35da..d67804a 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,19 +1,21 @@
{
"name": "nodeflow-frontend",
- "version": "2.0.1",
+ "version": "2.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nodeflow-frontend",
- "version": "2.0.1",
+ "version": "2.1.0",
"dependencies": {
"@mantine/core": "^8.3.10",
"@mantine/hooks": "^8.3.10",
+ "@monaco-editor/react": "^4.7.0",
"@tabler/icons-react": "^3.36.1",
"@tanstack/react-query": "^5.90.20",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
+ "monaco-editor": "^0.52.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.0"
@@ -890,6 +892,29 @@
"react": "^18.x || ^19.x"
}
},
+ "node_modules/@monaco-editor/loader": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
+ "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
+ "license": "MIT",
+ "dependencies": {
+ "state-local": "^1.0.6"
+ }
+ },
+ "node_modules/@monaco-editor/react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
+ "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@monaco-editor/loader": "^1.5.0"
+ },
+ "peerDependencies": {
+ "monaco-editor": ">= 0.25.0 < 1",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -1704,6 +1729,12 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/monaco-editor": {
+ "version": "0.52.2",
+ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
+ "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2037,6 +2068,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/state-local": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
+ "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
+ "license": "MIT"
+ },
"node_modules/tabbable": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 782d69a..1c13ae6 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "nodeflow-frontend",
"private": true,
- "version": "2.0.1",
+ "version": "2.1.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -13,10 +13,12 @@
"dependencies": {
"@mantine/core": "^8.3.10",
"@mantine/hooks": "^8.3.10",
+ "@monaco-editor/react": "^4.7.0",
"@tabler/icons-react": "^3.36.1",
"@tanstack/react-query": "^5.90.20",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
+ "monaco-editor": "^0.52.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.0"
diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx
index 845ccd6..adcf310 100644
--- a/frontend/src/app/App.tsx
+++ b/frontend/src/app/App.tsx
@@ -7,6 +7,7 @@ const NodesOverviewPage = lazy(() => import('../pages/NodesOverviewPage').then((
const NodeDetailPage = lazy(() => import('../pages/NodeDetailPage').then((module) => ({ default: module.NodeDetailPage })));
const RouteEditorPage = lazy(() => import('../pages/RouteEditorPage').then((module) => ({ default: module.RouteEditorPage })));
const SettingsPage = lazy(() => import('../pages/SettingsPage').then((module) => ({ default: module.SettingsPage })));
+const HAProxyConfigPage = lazy(() => import('../pages/HAProxyConfigPage').then((module) => ({ default: module.HAProxyConfigPage })));
const TrafficPage = lazy(() => import('../pages/TrafficPage').then((module) => ({ default: module.TrafficPage })));
export function App() {
@@ -18,6 +19,7 @@ export function App() {
} />
}>
} />
}>
} />
+
}>
} />
}>
} />
}>
} />
}>
} />
diff --git a/frontend/src/features/haproxy-editor/demoConfig.ts b/frontend/src/features/haproxy-editor/demoConfig.ts
new file mode 100644
index 0000000..2e0038e
--- /dev/null
+++ b/frontend/src/features/haproxy-editor/demoConfig.ts
@@ -0,0 +1,56 @@
+// Sample configuration shown by the HAProxy editor in demo mode.
+export const DEMO_HAPROXY_CONFIG = `# Generated by NodeFlow. Do not edit.
+# renderer: haproxy-tcp-sni-v21
+
+global
+ maxconn 20000
+ log /dev/log local0
+ log /dev/log local1 notice
+ stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
+ stats timeout 30s
+ user haproxy
+ group haproxy
+ tune.pipesize 262144
+ localpeer nf_local
+
+defaults
+ log global
+ mode tcp
+ option tcplog
+ option dontlognull
+ option clitcpka
+ option srvtcpka
+ .if enabled(SPLICE)
+ option splice-request
+ option splice-response
+ .endif
+ timeout connect 5s
+ timeout client 15m
+ timeout server 15m
+
+peers nf_peers
+ bind unix@/run/haproxy/nf_peers.sock
+ server nf_local
+
+frontend nf_fe_any_443_1a2b3c4d
+ bind :443
+ mode tcp
+ option tcplog
+ tcp-request inspect-delay 5s
+ tcp-request content accept if { req.ssl_hello_type 1 }
+ acl nf_sni_7f3e req.ssl_sni -i vpn.example.com
+ use_backend nf_be_7f3e if nf_sni_7f3e
+ default_backend nf_be_91aa
+
+backend nf_be_7f3e
+ mode tcp
+ # nodeflow route_id=7f3e0000-0000-0000-0000-000000000001
+ balance leastconn
+ option redispatch
+ server nf_srv_7f3e 192.0.2.10:443 check inter 5s fall 3 rise 2 send-proxy-v2 check-send-proxy
+
+backend nf_be_91aa
+ mode tcp
+ # nodeflow route_id=91aa0000-0000-0000-0000-000000000002
+ server nf_srv_91aa 192.0.2.20:8443 check inter 5s fall 3 rise 2
+`;
diff --git a/frontend/src/features/haproxy-editor/haproxyDocs.ts b/frontend/src/features/haproxy-editor/haproxyDocs.ts
new file mode 100644
index 0000000..e5515a5
--- /dev/null
+++ b/frontend/src/features/haproxy-editor/haproxyDocs.ts
@@ -0,0 +1,395 @@
+// Russian hover documentation for haproxy.cfg. Pure data: no runtime imports,
+// so node --test can load it with built-in type stripping.
+//
+// Keys are lowercase. Compound directives ("timeout connect", "option tcplog")
+// are keyed by their full phrase. Argument docs are grouped by the directive
+// that owns them (server options, bind options, balance algorithms…).
+
+export type SectionKind = 'global' | 'defaults' | 'frontend' | 'backend' | 'listen' | 'resolvers' | 'peers' | 'userlist' | 'cache' | 'program' | 'ring' | 'http-errors' | 'crt-store' | 'mailers' | 'log-forward';
+
+export interface HAProxyDoc {
+ /** 1–2 sentences, Russian. */
+ summary: string;
+ /** One syntax line, rendered as a haproxy code block. */
+ syntax?: string;
+ /** Markdown warning line, «⚠ Если **…**, то …». */
+ warning?: string;
+ /** Sections where a directive is valid (used for completion). */
+ sections?: SectionKind[];
+}
+
+const PROXY: SectionKind[] = ['defaults', 'frontend', 'backend', 'listen'];
+const FE: SectionKind[] = ['defaults', 'frontend', 'listen'];
+const BE: SectionKind[] = ['defaults', 'backend', 'listen'];
+const BE_ONLY: SectionKind[] = ['backend', 'listen'];
+const GLOBAL: SectionKind[] = ['global'];
+const RESOLVERS: SectionKind[] = ['resolvers'];
+
+export const SECTION_KEYWORDS: readonly string[] = ['global', 'defaults', 'frontend', 'backend', 'listen', 'resolvers', 'peers', 'userlist', 'cache', 'program', 'ring', 'http-errors', 'crt-store', 'mailers', 'log-forward'];
+
+export const GENERATED_NAME_DOC: HAProxyDoc = {
+ summary: 'Имя сгенерировано NodeFlow; на него завязаны метрики и квоты. **Не переименовывать.**',
+};
+
+export const sectionDocs: Record
= {
+ global: { summary: 'Параметры процесса HAProxy: лимиты, потоки, логи, stats-сокет, пользователь. Секция одна на весь конфиг.', syntax: 'global' },
+ defaults: { summary: 'Значения по умолчанию для всех следующих frontend/backend/listen: режим, таймауты, опции логирования.', syntax: 'defaults [<имя>]', warning: '⚠ Если добавить **второй defaults**, то он применяется только к секциям ниже него.' },
+ frontend: { summary: 'Точка входа: слушает порты (bind), принимает соединения и выбирает backend по правилам.', syntax: 'frontend <имя>' },
+ backend: { summary: 'Группа серверов назначения, алгоритм балансировки и проверки здоровья.', syntax: 'backend <имя>' },
+ listen: { summary: 'Frontend и backend в одной секции: bind и server рядом. Удобно для простых TCP-прокси.', syntax: 'listen <имя>' },
+ resolvers: { summary: 'Набор DNS-серверов, через которые HAProxy перерезолвит имена серверов во время работы.', syntax: 'resolvers <имя>' },
+ peers: { summary: 'Синхронизация stick-table между процессами HAProxy (в NodeFlow — между старым и новым процессом при reload).', syntax: 'peers <имя>' },
+ userlist: { summary: 'Список пользователей и групп для HTTP basic-auth.', syntax: 'userlist <имя>' },
+ cache: { summary: 'Кеш HTTP-ответов в памяти. Для TCP-маршрутов не используется.', syntax: 'cache <имя>' },
+ program: { summary: 'Внешняя программа, которую запускает и перезапускает master-процесс HAProxy.', syntax: 'program <имя>' },
+ ring: { summary: 'Кольцевой буфер в памяти для логов и трассировок.', syntax: 'ring <имя>' },
+ 'http-errors': { summary: 'Набор пользовательских страниц ошибок (errorfile) для HTTP-режима.', syntax: 'http-errors <имя>' },
+ 'crt-store': { summary: 'Хранилище сертификатов и ключей, на которые ссылаются bind-строки.', syntax: 'crt-store [<имя>]' },
+ mailers: { summary: 'SMTP-серверы для почтовых уведомлений об изменении состояния серверов.', syntax: 'mailers <имя>' },
+ 'log-forward': { summary: 'Приём и пересылка syslog-сообщений через HAProxy.', syntax: 'log-forward <имя>' },
+};
+
+export const directiveDocs: Record = {
+ // ---- conditional blocks
+ '.if': { summary: 'Условный блок конфигурации: строки до .endif читаются, только если условие истинно (проверяется при загрузке конфига).', syntax: '.if <условие> … [.elif …] [.else] .endif', warning: '⚠ Если не закрыть блок **.endif**, то HAProxy не загрузит конфиг.' },
+ '.elif': { summary: 'Альтернативная ветка условного блока .if.', syntax: '.elif <условие>' },
+ '.else': { summary: 'Ветка «иначе» условного блока .if.', syntax: '.else' },
+ '.endif': { summary: 'Закрывает условный блок .if.', syntax: '.endif' },
+ '.notice': { summary: 'Выводит сообщение уровня notice при загрузке конфига.', syntax: '.notice "<текст>"' },
+ '.warning': { summary: 'Выводит предупреждение при загрузке конфига.', syntax: '.warning "<текст>"' },
+ '.alert': { summary: 'Выводит ошибку и прерывает загрузку конфига.', syntax: '.alert "<текст>"' },
+ '.diag': { summary: 'Выводит диагностическое сообщение (только с ключом -dD).', syntax: '.diag "<текст>"' },
+
+ // ---- global
+ maxconn: { summary: 'Максимум одновременных соединений: в global — на весь процесс, во frontend — на этот вход. Лишние соединения ждут в очереди ядра.', syntax: 'maxconn <число>', warning: '⚠ Если поставить **слишком мало**, то новые клиенты будут ждать или отваливаться по таймауту.', sections: ['global', 'defaults', 'frontend', 'listen'] },
+ nbthread: { summary: 'Число рабочих потоков HAProxy. По умолчанию — по числу доступных CPU.', syntax: 'nbthread <число>', warning: '⚠ Если указать **больше ядер**, чем есть, то потоки конкурируют и задержка растёт.', sections: GLOBAL },
+ log: { summary: 'Куда отправлять логи: syslog-адрес или сокет, facility и уровень. «log global» в прокси берёт настройки из global.', syntax: 'log <адрес> [len ] [<уровень>] | log global', warning: '⚠ Если писать лог **каждого соединения** на маленький диск, то syslog быстро заполнит место.', sections: ['global', 'defaults', 'frontend', 'backend', 'listen'] },
+ 'log global': { summary: 'Использовать те же log-цели, что заданы в секции global.', syntax: 'log global', sections: PROXY },
+ 'log-format': { summary: 'Шаблон строки лога соединения с переменными вида %ci, %ft, %B.', syntax: 'log-format "<формат>"', sections: PROXY },
+ 'stats socket': { summary: 'UNIX-сокет Runtime API: статистика, включение/выключение серверов, веса, таблицы. NodeFlow Agent читает через него метрики и управляет квотами.', syntax: 'stats socket <путь> [mode <права>] [level <уровень>] [expose-fd listeners]', warning: '⚠ Если **удалить или переименовать** /run/haproxy/admin.sock, то метрики, квоты и мягкий reload в NodeFlow перестанут работать.', sections: GLOBAL },
+ 'stats timeout': { summary: 'Сколько ждать команду на stats-сокете, прежде чем закрыть соединение.', syntax: 'stats timeout <время>', sections: GLOBAL },
+ user: { summary: 'Пользователь, от имени которого работает HAProxy после старта.', syntax: 'user <имя>', sections: GLOBAL },
+ group: { summary: 'Группа, от имени которой работает HAProxy после старта.', syntax: 'group <имя>', sections: GLOBAL },
+ pidfile: { summary: 'Файл, в который записывается PID процесса.', syntax: 'pidfile <путь>', sections: GLOBAL },
+ daemon: { summary: 'Запуск в фоне. Под systemd обычно не нужен: сервис использует master-worker режим.', syntax: 'daemon', sections: GLOBAL },
+ 'master-worker': { summary: 'Режим master-worker: мастер-процесс перезапускает воркеры при reload без потери сокетов.', syntax: 'master-worker', sections: GLOBAL },
+ chroot: { summary: 'Переводит процесс в chroot-каталог после старта.', syntax: 'chroot <каталог>', warning: '⚠ Если включить **chroot**, то пути к файлам (acl -f, errorfile) должны быть доступны внутри каталога.', sections: GLOBAL },
+ 'hard-stop-after': { summary: 'Сколько старый процесс может дожидаться завершения соединений после reload, прежде чем закрыть их принудительно.', syntax: 'hard-stop-after <время>', warning: '⚠ Если значение **не задано**, то старые процессы с долгими TCP-сессиями живут неограниченно и держат память.', sections: GLOBAL },
+ 'ssl-default-bind-ciphers': { summary: 'Список шифров TLS ≤1.2 по умолчанию для bind с ssl.', syntax: 'ssl-default-bind-ciphers <список>', sections: GLOBAL },
+ 'ssl-default-bind-ciphersuites': { summary: 'Список шифров TLS 1.3 по умолчанию для bind с ssl.', syntax: 'ssl-default-bind-ciphersuites <список>', sections: GLOBAL },
+ 'ssl-default-bind-options': { summary: 'Опции TLS по умолчанию для bind: минимальная версия, отключение tickets и т.п.', syntax: 'ssl-default-bind-options ssl-min-ver TLSv1.2 …', sections: GLOBAL },
+ 'ssl-default-server-ciphers': { summary: 'Список шифров по умолчанию для TLS-соединений к серверам.', syntax: 'ssl-default-server-ciphers <список>', sections: GLOBAL },
+ 'ssl-default-server-options': { summary: 'Опции TLS по умолчанию для соединений к серверам.', syntax: 'ssl-default-server-options <опции>', sections: GLOBAL },
+ 'tune.pipesize': { summary: 'Размер pipe-буфера ядра для splice. NodeFlow подбирает его по объёму памяти ноды.', syntax: 'tune.pipesize <байт>', warning: '⚠ Если задать **слишком большой** размер на ноде с малой памятью, то под нагрузкой не хватит RAM.', sections: GLOBAL },
+ 'tune.bufsize': { summary: 'Размер буфера на соединение. Влияет на память и на максимальный размер заголовков.', syntax: 'tune.bufsize <байт>', warning: '⚠ Если **увеличить** bufsize, то память на maxconn соединений растёт пропорционально.', sections: GLOBAL },
+ 'tune.maxrewrite': { summary: 'Резерв буфера под перезапись HTTP-заголовков.', syntax: 'tune.maxrewrite <байт>', sections: GLOBAL },
+ 'tune.ssl.default-dh-param': { summary: 'Размер DH-параметров для TLS.', syntax: 'tune.ssl.default-dh-param <бит>', sections: GLOBAL },
+ localpeer: { summary: 'Имя этого процесса в секции peers. NodeFlow использует его для переноса stick-table между процессами при reload.', syntax: 'localpeer <имя>', sections: GLOBAL },
+ noreuseport: { summary: 'Отключает SO_REUSEPORT на слушающих сокетах.', syntax: 'noreuseport', sections: GLOBAL },
+ 'cpu-map': { summary: 'Привязка потоков HAProxy к ядрам CPU.', syntax: 'cpu-map [auto:]<поток> ', sections: GLOBAL },
+ 'ulimit-n': { summary: 'Лимит открытых файлов процесса. По умолчанию вычисляется из maxconn.', syntax: 'ulimit-n <число>', sections: GLOBAL },
+ 'expose-experimental-directives': { summary: 'Разрешает экспериментальные директивы.', syntax: 'expose-experimental-directives', sections: GLOBAL },
+
+ // ---- defaults / proxies
+ mode: { summary: 'Режим прокси: tcp — прозрачная пересылка байтов (NodeFlow), http — разбор HTTP.', syntax: 'mode tcp|http', warning: '⚠ Если frontend и backend в **разных режимах**, то HAProxy откажется загружать конфиг.', sections: PROXY },
+ 'timeout connect': { summary: 'Сколько ждать установления TCP-соединения с сервером.', syntax: 'timeout connect <время>', warning: '⚠ Если значение **слишком большое**, то клиент долго ждёт при недоступном сервере вместо перехода на другой.', sections: PROXY },
+ 'timeout client': { summary: 'Максимальная пауза без данных со стороны клиента. После неё соединение закрывается.', syntax: 'timeout client <время>', warning: '⚠ Если поставить **слишком мало** для VPN/прокси-трафика, то простаивающие сессии будут рваться.', sections: FE },
+ 'timeout server': { summary: 'Максимальная пауза без данных со стороны сервера.', syntax: 'timeout server <время>', warning: '⚠ Если значение **меньше timeout client**, то длинные сессии обрываются со стороны сервера.', sections: BE },
+ 'timeout tunnel': { summary: 'Таймаут бездействия для установившегося туннеля (WebSocket, CONNECT, TCP). Заменяет client/server после установления.', syntax: 'timeout tunnel <время>', sections: BE },
+ 'timeout check': { summary: 'Дополнительный таймаут на чтение ответа health-check после соединения.', syntax: 'timeout check <время>', sections: BE },
+ 'timeout queue': { summary: 'Сколько соединение может ждать в очереди, когда у серверов исчерпан maxconn.', syntax: 'timeout queue <время>', sections: BE },
+ 'timeout client-fin': { summary: 'Таймаут бездействия клиента после полузакрытия соединения (FIN).', syntax: 'timeout client-fin <время>', sections: FE },
+ 'timeout server-fin': { summary: 'Таймаут бездействия сервера после полузакрытия соединения (FIN).', syntax: 'timeout server-fin <время>', sections: BE },
+ 'timeout http-request': { summary: 'Время на получение полного HTTP-запроса (защита от slowloris).', syntax: 'timeout http-request <время>', sections: FE },
+ 'timeout http-keep-alive': { summary: 'Сколько ждать следующий HTTP-запрос в keep-alive соединении.', syntax: 'timeout http-keep-alive <время>', sections: PROXY },
+ 'timeout resolve': { summary: 'Интервал периодического перерезолва имён в секции resolvers.', syntax: 'timeout resolve <время>', sections: RESOLVERS },
+ 'timeout retry': { summary: 'Сколько ждать ответа DNS-сервера перед повторным запросом.', syntax: 'timeout retry <время>', sections: RESOLVERS },
+ 'option tcplog': { summary: 'Расширенный формат лога для TCP: адреса, время, байты, состояние завершения.', syntax: 'option tcplog', sections: PROXY },
+ 'option httplog': { summary: 'Расширенный формат лога для HTTP.', syntax: 'option httplog', sections: PROXY },
+ 'option dontlognull': { summary: 'Не логировать соединения без данных (сканеры портов, проверки балансировщиков).', syntax: 'option dontlognull', sections: FE },
+ 'option splice-request': { summary: 'Пересылка данных клиент→сервер через splice ядра без копирования в user space. Экономит CPU.', syntax: 'option splice-request', sections: PROXY },
+ 'option splice-response': { summary: 'Пересылка данных сервер→клиент через splice ядра без копирования в user space.', syntax: 'option splice-response', sections: PROXY },
+ 'option splice-auto': { summary: 'HAProxy сам решает, когда использовать splice.', syntax: 'option splice-auto', sections: PROXY },
+ 'option redispatch': { summary: 'При неудачном подключении разрешает повтор на другой сервер, даже если клиент «прилип» к исходному.', syntax: 'option redispatch [<интервал>]', sections: BE },
+ 'option tcp-check': { summary: 'Health-check через TCP-сценарий (tcp-check connect/send/expect). Без правил — просто проверка соединения.', syntax: 'option tcp-check', sections: BE },
+ 'option httpchk': { summary: 'Health-check HTTP-запросом; сервер считается живым при ответе 2xx/3xx.', syntax: 'option httpchk [<метод> ]', sections: BE },
+ 'option allbackups': { summary: 'Когда все основные серверы недоступны — балансировать между всеми backup-серверами, а не только первым.', syntax: 'option allbackups', sections: BE },
+ 'option clitcpka': { summary: 'Включить TCP keepalive к клиенту: ядро будет замечать «мёртвые» клиентские соединения.', syntax: 'option clitcpka', sections: FE },
+ 'option srvtcpka': { summary: 'Включить TCP keepalive к серверу.', syntax: 'option srvtcpka', sections: BE },
+ 'option tcpka': { summary: 'TCP keepalive в обе стороны.', syntax: 'option tcpka', sections: PROXY },
+ 'option forwardfor': { summary: 'Добавлять заголовок X-Forwarded-For (только HTTP-режим).', syntax: 'option forwardfor', sections: PROXY },
+ 'option abortonclose': { summary: 'Снимать запрос из очереди, если клиент уже закрыл соединение.', syntax: 'option abortonclose', sections: BE },
+ 'option log-health-checks': { summary: 'Логировать смену результатов health-check.', syntax: 'option log-health-checks', sections: BE },
+ 'no option': { summary: 'Отключает опцию, унаследованную из defaults.', syntax: 'no option <опция>', sections: PROXY },
+ 'clitcpka-idle': { summary: 'Через сколько простоя ядро начинает слать keepalive-пробы клиенту.', syntax: 'clitcpka-idle <время>', sections: FE },
+ 'clitcpka-intvl': { summary: 'Интервал между keepalive-пробами клиенту.', syntax: 'clitcpka-intvl <время>', sections: FE },
+ 'clitcpka-cnt': { summary: 'Сколько неотвеченных keepalive-проб до закрытия клиентского соединения.', syntax: 'clitcpka-cnt <число>', sections: FE },
+ 'srvtcpka-idle': { summary: 'Через сколько простоя ядро начинает слать keepalive-пробы серверу.', syntax: 'srvtcpka-idle <время>', sections: BE },
+ 'srvtcpka-intvl': { summary: 'Интервал между keepalive-пробами серверу.', syntax: 'srvtcpka-intvl <время>', sections: BE },
+ 'srvtcpka-cnt': { summary: 'Сколько неотвеченных keepalive-проб до закрытия серверного соединения.', syntax: 'srvtcpka-cnt <число>', sections: BE },
+ retries: { summary: 'Сколько раз повторять неудачное подключение к серверу.', syntax: 'retries <число>', sections: BE },
+ balance: { summary: 'Алгоритм выбора сервера в backend.', syntax: 'balance roundrobin|static-rr|leastconn|first|source|random[()]|hash <выражение>', warning: '⚠ Если сменить алгоритм **с source/hash**, то клиенты потеряют привязку к серверам.', sections: BE },
+ 'hash-type': { summary: 'Как хеш (balance source/hash) отображается на серверы: map-based — быстро, consistent — минимальные перестановки при изменениях.', syntax: 'hash-type map-based|consistent [<функция>] [<модификатор>]', sections: BE },
+ 'hash-balance-factor': { summary: 'Для consistent-хеша: сервер не получит больше указанного процента от средней нагрузки.', syntax: 'hash-balance-factor <процент>', sections: BE },
+ bind: { summary: 'Адрес и порт, на которых слушает frontend. Можно указать несколько bind.', syntax: 'bind [<адрес>]:<порт>[,…] [опции]', warning: '⚠ Если порт **уже занят** другим процессом, то HAProxy не запустится после reload.', sections: ['frontend', 'listen', 'peers', 'log-forward'] },
+ 'tcp-request inspect-delay': { summary: 'Сколько ждать данные клиента для проверки content-правил (например, SNI из TLS ClientHello).', syntax: 'tcp-request inspect-delay <время>', warning: '⚠ Если убрать **inspect-delay**, то SNI-правила не успеют увидеть ClientHello и трафик уйдёт в default_backend.', sections: PROXY },
+ 'tcp-request content': { summary: 'Правило над содержимым соединения после inspect-delay: accept, reject, установка лимитов и т.п.', syntax: 'tcp-request content <действие> [if|unless <условие>]', sections: PROXY },
+ 'tcp-request connection': { summary: 'Правило сразу после accept(), до чтения данных: принять, отклонить, ожидать PROXY-заголовок.', syntax: 'tcp-request connection <действие> [if|unless <условие>]', sections: FE },
+ 'tcp-request session': { summary: 'Правило после handshake (PROXY/TLS), до чтения данных.', syntax: 'tcp-request session <действие> [if|unless <условие>]', sections: FE },
+ 'tcp-response content': { summary: 'Правило над данными от сервера.', syntax: 'tcp-response content <действие> [if|unless <условие>]', sections: BE },
+ 'tcp-check connect': { summary: 'Шаг TCP health-check: открыть соединение.', syntax: 'tcp-check connect [port ] [send-proxy]', sections: BE },
+ 'tcp-check send': { summary: 'Шаг TCP health-check: отправить строку.', syntax: 'tcp-check send <строка>', sections: BE },
+ 'tcp-check expect': { summary: 'Шаг TCP health-check: ожидаемый ответ.', syntax: 'tcp-check expect string|rstring <шаблон>', sections: BE },
+ use_backend: { summary: 'Отправить соединение в указанный backend, если условие истинно. Правила проверяются по порядку.', syntax: 'use_backend [if|unless <условие>]', warning: '⚠ Если backend **не существует**, то HAProxy не загрузит конфиг.', sections: FE },
+ default_backend: { summary: 'Backend для соединений, не подошедших ни под одно use_backend.', syntax: 'default_backend ', sections: FE },
+ acl: { summary: 'Именованное условие: выборка (src, req.ssl_sni…) плюс значения для сравнения. Используется в if/unless.', syntax: 'acl <имя> <выборка> [флаги] <значения>', sections: PROXY },
+ server: { summary: 'Сервер назначения в backend: имя, адрес:порт и опции проверок, веса, PROXY protocol.', syntax: 'server <имя> <адрес>[:<порт>] [опции]', warning: '⚠ Если **переименовать** сервер nf_srv_*, то NodeFlow потеряет его метрики и квоты.', sections: ['backend', 'listen', 'peers'] },
+ 'server-template': { summary: 'Создаёт N серверов из одного DNS-имени: каждый слот получает один из адресов записи.', syntax: 'server-template <префикс> <кол-во> :<порт> [опции]', sections: BE_ONLY },
+ 'default-server': { summary: 'Опции по умолчанию для всех server в секции.', syntax: 'default-server [опции]', sections: BE },
+ 'stick-table': { summary: 'Таблица в памяти для привязки клиентов к серверам или счётчиков (скорость, соединения).', syntax: 'stick-table type <тип> size [expire <время>] [peers <секция>] [store <данные>]', warning: '⚠ Если **size** мал для числа клиентов, то старые записи вытесняются и привязка теряется.', sections: PROXY },
+ 'stick on': { summary: 'Запомнить в stick-table, на какой сервер ушёл клиент, и отправлять его туда же.', syntax: 'stick on <выражение> [table <таблица>]', sections: BE_ONLY },
+ 'stick match': { summary: 'Искать клиента в stick-table без записи.', syntax: 'stick match <выражение> [table <таблица>]', sections: BE_ONLY },
+ 'stick store-request': { summary: 'Записывать клиента в stick-table без поиска.', syntax: 'stick store-request <выражение>', sections: BE_ONLY },
+ 'filter bwlim-in': { summary: 'Фильтр ограничения скорости входящего (upload) трафика. Лимит применяется через tcp-request content set-bandwidth-limit.', syntax: 'filter bwlim-in <имя> limit <скорость> key <выражение> [table <таблица>] [min-size <байт>]', sections: PROXY },
+ 'filter bwlim-out': { summary: 'Фильтр ограничения скорости исходящего (download) трафика.', syntax: 'filter bwlim-out <имя> limit <скорость> key <выражение> [table <таблица>] [min-size <байт>]', sections: PROXY },
+ filter: { summary: 'Подключает фильтр потока данных (bwlim-in/out, compression, trace…).', syntax: 'filter <тип> [параметры]', sections: PROXY },
+ errorfile: { summary: 'Файл ответа для HTTP-кода ошибки.', syntax: 'errorfile <код> <файл>', sections: PROXY },
+ 'http-request': { summary: 'Правило обработки HTTP-запроса (только mode http).', syntax: 'http-request <действие> [if|unless <условие>]', sections: PROXY },
+ 'http-response': { summary: 'Правило обработки HTTP-ответа (только mode http).', syntax: 'http-response <действие> [if|unless <условие>]', sections: PROXY },
+ 'http-check': { summary: 'Шаг HTTP health-check.', syntax: 'http-check <действие> …', sections: BE },
+ fullconn: { summary: 'Нагрузка на backend, при которой серверы используют полный maxconn (для динамического minconn).', syntax: 'fullconn <число>', sections: BE },
+ backlog: { summary: 'Длина очереди ядра для ещё не принятых соединений.', syntax: 'backlog <число>', sections: FE },
+ 'rate-limit sessions': { summary: 'Ограничение новых сессий в секунду на frontend.', syntax: 'rate-limit sessions <число>', sections: FE },
+ description: { summary: 'Текстовое описание секции (видно в статистике).', syntax: 'description <текст>', sections: PROXY },
+ disabled: { summary: 'Секция загружается, но не работает.', syntax: 'disabled', sections: PROXY },
+ enabled: { summary: 'Явно включает секцию (по умолчанию).', syntax: 'enabled', sections: PROXY },
+ 'monitor-uri': { summary: 'URI для внешних health-проверок самого HAProxy.', syntax: 'monitor-uri ', sections: FE },
+ source: { summary: 'Исходный адрес для соединений к серверам.', syntax: 'source <адрес>[:<порт>]', sections: BE },
+
+ // ---- resolvers
+ nameserver: { summary: 'DNS-сервер для этой секции resolvers.', syntax: 'nameserver <имя> <адрес>:<порт>', sections: RESOLVERS },
+ resolve_retries: { summary: 'Сколько раз повторять DNS-запрос, прежде чем считать резолв неудачным.', syntax: 'resolve_retries <число>', sections: RESOLVERS },
+ hold: { summary: 'Сколько хранить последний результат DNS для заданного статуса ответа (valid, nx, timeout…).', syntax: 'hold <статус> <время>', warning: '⚠ Если **hold valid** слишком велик, то смена IP у бэкенда подхватится с задержкой.', sections: RESOLVERS },
+ 'parse-resolv-conf': { summary: 'Взять nameserver-ы из /etc/resolv.conf.', syntax: 'parse-resolv-conf', sections: RESOLVERS },
+ 'accepted_payload_size': { summary: 'Максимальный размер DNS-ответа по UDP (EDNS0).', syntax: 'accepted_payload_size <байт>', sections: RESOLVERS },
+ peer: { summary: 'Участник синхронизации stick-table.', syntax: 'peer <имя> <адрес>:<порт>', sections: ['peers'] },
+};
+
+/** Arguments/options, grouped by the directive that owns them. */
+const serverOptions: Record = {
+ check: { summary: 'Включает health-check сервера. Упавший сервер исключается из балансировки.', syntax: 'server … check [inter <время>] [fall ] [rise ]', warning: '⚠ Если **check** не указан, то HAProxy шлёт трафик на сервер, даже если он недоступен.' },
+ inter: { summary: 'Интервал между health-check.', syntax: 'inter <время> # по умолчанию 2s' },
+ fastinter: { summary: 'Интервал проверок во время перехода состояния.', syntax: 'fastinter <время>' },
+ downinter: { summary: 'Интервал проверок, пока сервер лежит.', syntax: 'downinter <время>' },
+ fall: { summary: 'Сколько подряд неудачных проверок до статуса DOWN.', syntax: 'fall <число> # по умолчанию 3' },
+ rise: { summary: 'Сколько подряд успешных проверок до статуса UP.', syntax: 'rise <число> # по умолчанию 2' },
+ weight: { summary: 'Вес сервера при балансировке: доля трафика пропорциональна весу. 0 — сервер не получает новых соединений.', syntax: 'weight <0-256>', warning: '⚠ Если NodeFlow управляет весами (leastping), то ручные **weight** перезапишутся Agent-ом.' },
+ backup: { summary: 'Резервный сервер: получает трафик, только когда все основные недоступны.', syntax: 'server … backup' },
+ 'send-proxy': { summary: 'Отправлять серверу PROXY protocol v1 (текстовый) с реальным адресом клиента.', syntax: 'server … send-proxy', warning: '⚠ Если сервер **не ждёт** PROXY-заголовок, то все соединения к нему будут ломаться.' },
+ 'send-proxy-v2': { summary: 'Отправлять серверу PROXY protocol v2 (бинарный) с реальным адресом клиента.', syntax: 'server … send-proxy-v2', warning: '⚠ Если сервер **не ждёт** PROXY v2, то все соединения к нему будут ломаться.' },
+ 'check-send-proxy': { summary: 'Слать PROXY-заголовок и в health-check, когда сервер требует его на каждом соединении.', syntax: 'server … check send-proxy-v2 check-send-proxy', warning: '⚠ Если сервер ждёт PROXY, а **check-send-proxy** не указан, то проверки падают и сервер помечается DOWN.' },
+ resolvers: { summary: 'Секция resolvers для перерезолва DNS-имени сервера во время работы.', syntax: 'resolvers <секция>' },
+ 'init-addr': { summary: 'Как получить адрес при старте: last — из state-файла, libc — системным резолвером, none — стартовать без адреса.', syntax: 'init-addr last,libc,none|', warning: '⚠ Если **none** не указан, а DNS недоступен при старте, то HAProxy не запустится.' },
+ maxconn: { summary: 'Максимум одновременных соединений к этому серверу; остальные ждут в очереди backend.', syntax: 'maxconn <число>' },
+ slowstart: { summary: 'Плавный разгон веса сервера после возврата в UP, чтобы не завалить его трафиком.', syntax: 'slowstart <время>' },
+ 'resolve-opts': { summary: 'Опции DNS-резолва сервера: prevent-dup-ip — не давать двум слотам один IP, allow-dup-ip, ignore-weight.', syntax: 'resolve-opts prevent-dup-ip[,…]' },
+ 'resolve-prefer': { summary: 'Предпочитаемое семейство адресов при резолве.', syntax: 'resolve-prefer ipv4|ipv6' },
+ 'hash-key': { summary: 'Что использовать как ключ сервера для consistent-хеша: id, addr или addr-port. addr сохраняет привязку при смене порядка слотов.', syntax: 'hash-key id|addr|addr-port' },
+ disabled: { summary: 'Сервер в режиме обслуживания (MAINT) с момента запуска.', syntax: 'server … disabled' },
+ ssl: { summary: 'Шифровать соединение к серверу TLS.', syntax: 'server … ssl [verify none|required]' },
+ verify: { summary: 'Проверка сертификата сервера.', syntax: 'verify none|required' },
+ sni: { summary: 'SNI для TLS-соединения к серверу.', syntax: 'sni <выражение>' },
+ port: { summary: 'Порт для health-check, если отличается от порта трафика.', syntax: 'port <порт>' },
+ addr: { summary: 'Адрес для health-check, если отличается от адреса трафика.', syntax: 'addr <адрес>' },
+ 'agent-check': { summary: 'Включить agent-check: внешний агент сообщает вес и состояние сервера.', syntax: 'agent-check agent-port <порт>' },
+ 'on-marked-down': { summary: 'Что делать при падении сервера: shutdown-sessions — закрыть его сессии.', syntax: 'on-marked-down shutdown-sessions' },
+ 'on-marked-up': { summary: 'Что делать при возврате сервера: shutdown-backup-sessions.', syntax: 'on-marked-up shutdown-backup-sessions' },
+ observe: { summary: 'Пассивная проверка здоровья по ошибкам реального трафика.', syntax: 'observe layer4|layer7' },
+ track: { summary: 'Брать состояние из другого сервера вместо своих проверок.', syntax: 'track [/]<сервер>' },
+ cookie: { summary: 'Значение cookie для привязки в HTTP-режиме.', syntax: 'cookie <значение>' },
+ id: { summary: 'Числовой идентификатор сервера.', syntax: 'id <число>' },
+};
+
+const bindOptions: Record = {
+ 'accept-proxy': { summary: 'Требовать PROXY protocol (v1/v2) от каждого входящего соединения — адрес клиента берётся из заголовка.', syntax: 'bind :443 accept-proxy', warning: '⚠ Если клиенты подключаются **напрямую**, без PROXY-заголовка, то их соединения будут отклонены.' },
+ v4v6: { summary: 'Слушать IPv4 и IPv6 на одном сокете «::».', syntax: 'bind :::443 v4v6' },
+ v6only: { summary: 'Сокет «::» принимает только IPv6.', syntax: 'bind :::443 v6only' },
+ reuseport: { summary: 'В HAProxy это не опция bind: SO_REUSEPORT включён по умолчанию, а отключается глобальной директивой noreuseport.', syntax: 'global\n noreuseport' },
+ transparent: { summary: 'Слушать нелокальный адрес (TPROXY).', syntax: 'bind 10.0.0.1:443 transparent' },
+ tfo: { summary: 'Разрешить TCP Fast Open для входящих соединений.', syntax: 'bind :443 tfo' },
+ 'defer-accept': { summary: 'Будить HAProxy только когда клиент прислал данные.', syntax: 'bind :443 defer-accept' },
+ interface: { summary: 'Слушать только на указанном сетевом интерфейсе.', syntax: 'bind :443 interface eth0' },
+ name: { summary: 'Имя слушающего сокета в статистике.', syntax: 'name <имя>' },
+ ssl: { summary: 'Терминировать TLS на этом bind.', syntax: 'bind :443 ssl crt <файл>', warning: '⚠ Если включить **ssl** на SNI-маршрутизации NodeFlow, то HAProxy расшифрует трафик вместо прозрачной пересылки.' },
+ crt: { summary: 'Сертификат (PEM с ключом) или каталог сертификатов для ssl.', syntax: 'crt <путь>' },
+ alpn: { summary: 'Список ALPN-протоколов для TLS.', syntax: 'alpn h2,http/1.1' },
+ thread: { summary: 'Какими потоками обслуживать этот bind.', syntax: 'thread <потоки>' },
+ backlog: { summary: 'Очередь ядра для этого сокета.', syntax: 'backlog <число>' },
+ maxconn: { summary: 'Максимум одновременных соединений через этот bind.', syntax: 'maxconn <число>' },
+ mode: { summary: 'Права на UNIX-сокет (восьмеричные).', syntax: 'mode 660' },
+ user: { summary: 'Владелец UNIX-сокета.', syntax: 'user <имя>' },
+ group: { summary: 'Группа UNIX-сокета.', syntax: 'group <имя>' },
+ level: { summary: 'Уровень доступа к stats-сокету: user, operator или admin.', syntax: 'level user|operator|admin', warning: '⚠ Если понизить **level admin**, то NodeFlow Agent не сможет управлять весами и квотами.' },
+ admin: { summary: 'Полный уровень доступа к Runtime API.', syntax: 'level admin' },
+ 'expose-fd': { summary: 'Разрешить передачу слушающих сокетов новому процессу при reload — соединения не теряются.', syntax: 'expose-fd listeners', warning: '⚠ Если убрать **expose-fd listeners**, то при reload возможны отказы в соединении.' },
+ listeners: { summary: 'Аргумент expose-fd: передавать слушающие сокеты.', syntax: 'expose-fd listeners' },
+ namespace: { summary: 'Слушать в указанном network namespace.', syntax: 'namespace <имя>' },
+};
+
+const balanceArgs: Record = {
+ roundrobin: { summary: 'По очереди с учётом весов; веса можно менять на лету.', syntax: 'balance roundrobin' },
+ 'static-rr': { summary: 'По очереди с учётом весов, без ограничения на число серверов; веса не меняются на лету.', syntax: 'balance static-rr' },
+ leastconn: { summary: 'Сервер с наименьшим числом активных соединений (с учётом веса). Подходит для долгих TCP-сессий.', syntax: 'balance leastconn' },
+ first: { summary: 'Первый сервер с свободными слотами maxconn; остальные включаются по мере заполнения.', syntax: 'balance first' },
+ source: { summary: 'Хеш IP клиента: один клиент попадает на один сервер, пока набор серверов не меняется.', syntax: 'balance source', warning: '⚠ Если клиенты за **общим NAT**, то они все уйдут на один сервер.' },
+ random: { summary: 'Случайный выбор; random(N) — лучший из N случайных по числу соединений («power of two choices»).', syntax: 'balance random[()]' },
+ hash: { summary: 'Хеш произвольного выражения, например src,ipmask(32,64) — привязка по подсети.', syntax: 'balance hash <выражение>' },
+ uri: { summary: 'Хеш URI запроса (HTTP).', syntax: 'balance uri' },
+ hdr: { summary: 'Хеш HTTP-заголовка.', syntax: 'balance hdr(<имя>)' },
+ 'rdp-cookie': { summary: 'Хеш RDP-cookie.', syntax: 'balance rdp-cookie' },
+};
+
+const hashTypeArgs: Record = {
+ 'map-based': { summary: 'Статическая таблица: быстро, но при изменении набора серверов перераспределяется большинство клиентов.', syntax: 'hash-type map-based' },
+ consistent: { summary: 'Кольцо хешей: при добавлении/удалении сервера переезжает лишь малая доля клиентов.', syntax: 'hash-type consistent' },
+ sdbm: { summary: 'Хеш-функция sdbm (по умолчанию).', syntax: 'hash-type consistent sdbm' },
+ djb2: { summary: 'Хеш-функция djb2.', syntax: 'hash-type consistent djb2' },
+ wt6: { summary: 'Хеш-функция wt6.', syntax: 'hash-type consistent wt6' },
+ crc32: { summary: 'Хеш-функция crc32.', syntax: 'hash-type consistent crc32' },
+ none: { summary: 'Без хеш-функции: значение используется как есть.', syntax: 'hash-type consistent none' },
+ avalanche: { summary: 'Дополнительное перемешивание хеша — равномернее распределяет похожие ключи (соседние IP).', syntax: 'hash-type consistent sdbm avalanche' },
+};
+
+const stickTableArgs: Record = {
+ type: { summary: 'Тип ключа таблицы: ip, ipv6, integer, string, binary.', syntax: 'type ip|ipv6|integer|string [len ]' },
+ ip: { summary: 'Ключ — IPv4-адрес клиента.', syntax: 'type ip' },
+ ipv6: { summary: 'Ключ — IPv6-адрес (IPv4 хранится как отображённый).', syntax: 'type ipv6' },
+ integer: { summary: 'Ключ — целое число.', syntax: 'type integer' },
+ string: { summary: 'Ключ — строка.', syntax: 'type string len ' },
+ size: { summary: 'Максимум записей; суффиксы k, m, g.', syntax: 'size <число>[k|m|g]', warning: '⚠ Если таблица **переполнена**, то вытесняются самые старые записи.' },
+ expire: { summary: 'Время жизни неиспользуемой записи.', syntax: 'expire <время>' },
+ peers: { summary: 'Секция peers для синхронизации таблицы; в NodeFlow сохраняет привязки при reload.', syntax: 'peers <секция>' },
+ store: { summary: 'Какие счётчики хранить в записи.', syntax: 'store <счётчик>[,…]' },
+ nopurge: { summary: 'Не вытеснять записи при переполнении.', syntax: 'nopurge' },
+ bytes_in_rate: { summary: 'Скорость входящих байт за период — основа лимита upload.', syntax: 'store bytes_in_rate(<период>)' },
+ bytes_out_rate: { summary: 'Скорость исходящих байт за период — основа лимита download.', syntax: 'store bytes_out_rate(<период>)' },
+ conn_cur: { summary: 'Текущее число соединений ключа.', syntax: 'store conn_cur' },
+ conn_rate: { summary: 'Скорость новых соединений ключа.', syntax: 'store conn_rate(<период>)' },
+ server_id: { summary: 'ID сервера, к которому привязан ключ.', syntax: 'store server_id' },
+};
+
+const tcpRequestArgs: Record = {
+ accept: { summary: 'Принять соединение и прекратить проверку остальных правил этого уровня.', syntax: 'tcp-request content accept [if <условие>]' },
+ reject: { summary: 'Закрыть соединение.', syntax: 'tcp-request content reject [if <условие>]' },
+ 'expect-proxy': { summary: 'Ожидать PROXY protocol заголовок на этом соединении.', syntax: 'tcp-request connection expect-proxy layer4 [if <условие>]', warning: '⚠ Если заголовок ждётся от **всех** источников, то прямые клиенты без PROXY не подключатся.' },
+ layer4: { summary: 'Уровень правила: сразу после accept().', syntax: 'expect-proxy layer4' },
+ 'set-bandwidth-limit': { summary: 'Включить фильтр bwlim для соединения, опционально с лимитом и периодом.', syntax: 'tcp-request content set-bandwidth-limit <фильтр> [limit ] [period ]' },
+ 'track-sc0': { summary: 'Учитывать соединение в stick-table (счётчик sc0).', syntax: 'track-sc0 <ключ> [table <таблица>]' },
+ 'track-sc1': { summary: 'Учитывать соединение в stick-table (счётчик sc1).', syntax: 'track-sc1 <ключ> [table <таблица>]' },
+ 'silent-drop': { summary: 'Молча сбросить соединение без RST клиенту.', syntax: 'tcp-request … silent-drop' },
+ 'set-var': { summary: 'Записать значение в переменную.', syntax: 'set-var(<имя>) <выражение>' },
+ content: { summary: 'Уровень правил над содержимым (после inspect-delay).', syntax: 'tcp-request content …' },
+ connection: { summary: 'Уровень правил сразу после accept().', syntax: 'tcp-request connection …' },
+ session: { summary: 'Уровень правил после handshake.', syntax: 'tcp-request session …' },
+};
+
+const filterArgs: Record = {
+ 'bwlim-in': { summary: 'Ограничение скорости входящего (upload) трафика.', syntax: 'filter bwlim-in <имя> …' },
+ 'bwlim-out': { summary: 'Ограничение скорости исходящего (download) трафика.', syntax: 'filter bwlim-out <имя> …' },
+ limit: { summary: 'Лимит скорости в байтах в секунду (суффиксы k, m, g).', syntax: 'limit <байт/с>' },
+ key: { summary: 'Выражение-ключ, по которому считается лимит (обычно адрес клиента).', syntax: 'key <выражение>' },
+ table: { summary: 'Stick-table, где хранится текущая скорость по ключу.', syntax: 'table <таблица>' },
+ 'min-size': { summary: 'Минимальный размер порции данных, который фильтр пропускает за раз.', syntax: 'min-size <байт>' },
+ period: { summary: 'Период усреднения скорости.', syntax: 'period <время>' },
+ 'default-limit': { summary: 'Лимит по умолчанию для фильтра без ключа.', syntax: 'default-limit <байт/с>' },
+ 'default-period': { summary: 'Период по умолчанию.', syntax: 'default-period <время>' },
+};
+
+const holdArgs: Record = {
+ valid: { summary: 'Сколько использовать успешный ответ DNS до обновления.', syntax: 'hold valid <время>' },
+ nx: { summary: 'Сколько держать старый адрес при ответе NXDOMAIN.', syntax: 'hold nx <время>' },
+ obsolete: { summary: 'Сколько держать адрес, исчезнувший из ответа DNS.', syntax: 'hold obsolete <время>' },
+ timeout: { summary: 'Сколько держать старый адрес при таймауте DNS.', syntax: 'hold timeout <время>' },
+ refused: { summary: 'Сколько держать старый адрес при ответе REFUSED.', syntax: 'hold refused <время>' },
+ other: { summary: 'Сколько держать старый адрес при прочих ошибках DNS.', syntax: 'hold other <время>' },
+};
+
+const logArgs: Record = {
+ global: { summary: 'Использовать log-цели из секции global.', syntax: 'log global' },
+ notice: { summary: 'Уровень syslog notice: только значимые события (смена состояния серверов).', syntax: 'log <адрес> notice' },
+ info: { summary: 'Уровень syslog info: включает лог каждого соединения.', syntax: 'log <адрес> info' },
+ local0: { summary: 'Syslog facility local0 — лог соединений HAProxy.', syntax: 'log /dev/log local0' },
+ local1: { summary: 'Syslog facility local1.', syntax: 'log /dev/log local1 notice' },
+};
+
+const modeArgs: Record = {
+ tcp: { summary: 'Прозрачная TCP-пересылка без разбора протокола. Так работают все маршруты NodeFlow.', syntax: 'mode tcp' },
+ http: { summary: 'Разбор HTTP: заголовки, http-request правила, cookies.', syntax: 'mode http' },
+};
+
+const initAddrArgs: Record = {
+ last: { summary: 'Взять последний известный адрес из state-файла.', syntax: 'init-addr last' },
+ libc: { summary: 'Резолвить системным резолвером при старте.', syntax: 'init-addr libc' },
+ none: { summary: 'Разрешить старт без адреса: сервер в MAINT, пока DNS не ответит.', syntax: 'init-addr none' },
+};
+
+export const argDocs: Record> = {
+ server: serverOptions,
+ 'server-template': serverOptions,
+ 'default-server': serverOptions,
+ bind: bindOptions,
+ 'stats socket': bindOptions,
+ balance: balanceArgs,
+ 'hash-type': hashTypeArgs,
+ 'stick-table': stickTableArgs,
+ 'tcp-request': tcpRequestArgs,
+ filter: filterArgs,
+ hold: holdArgs,
+ log: logArgs,
+ mode: modeArgs,
+ 'init-addr': initAddrArgs,
+ 'resolve-opts': {
+ 'prevent-dup-ip': { summary: 'Не назначать один и тот же IP двум слотам server-template — каждый адрес DNS-записи получает свой слот.', syntax: 'resolve-opts prevent-dup-ip' },
+ 'allow-dup-ip': { summary: 'Разрешить нескольким слотам получить один IP.', syntax: 'resolve-opts allow-dup-ip' },
+ 'ignore-weight': { summary: 'Не брать вес из SRV-записей DNS.', syntax: 'resolve-opts ignore-weight' },
+ },
+ 'hash-key': {
+ id: { summary: 'Ключ consistent-хеша — числовой ID сервера.', syntax: 'hash-key id' },
+ addr: { summary: 'Ключ consistent-хеша — IP сервера: привязка клиентов сохраняется, даже если слот сменил адрес.', syntax: 'hash-key addr' },
+ 'addr-port': { summary: 'Ключ consistent-хеша — IP и порт сервера.', syntax: 'hash-key addr-port' },
+ },
+};
+
+/** Tokens meaningful anywhere: conditions, fetches, ACL flags, functions. */
+export const keywordDocs: Record = {
+ '{': { summary: 'Начало анонимного ACL: условие записывается прямо в правиле, без отдельной строки acl.', syntax: 'if { <выборка> [флаги] <значения> }', warning: '⚠ Если забыть **пробел** после { или перед }, то HAProxy не распознает условие.' },
+ '}': { summary: 'Конец анонимного ACL.', syntax: 'if { <выборка> <значения> }' },
+ if: { summary: 'Правило срабатывает, только если условие истинно. Несколько ACL подряд — «И», «||» или or — «ИЛИ».', syntax: '<правило> if […] [|| ]' },
+ unless: { summary: 'Правило срабатывает, только если условие ложно.', syntax: '<правило> unless ' },
+ or: { summary: 'Логическое «ИЛИ» между ACL в условии.', syntax: 'if acl1 or acl2' },
+ '||': { summary: 'Логическое «ИЛИ» между ACL в условии.', syntax: 'if acl1 || acl2' },
+ '!': { summary: 'Отрицание ACL.', syntax: 'if !acl1' },
+ src: { summary: 'Выборка: IP-адрес клиента (после PROXY protocol — реальный адрес из заголовка).', syntax: 'acl <имя> src <сеть>[ …] | -f <файл>' },
+ dst: { summary: 'Выборка: локальный IP, на который пришло соединение.', syntax: 'acl <имя> dst <адрес>' },
+ dst_port: { summary: 'Выборка: локальный порт, на который пришло соединение.', syntax: 'acl <имя> dst_port <порт>[:<порт>]' },
+ src_port: { summary: 'Выборка: порт клиента.', syntax: 'acl <имя> src_port <порт>' },
+ 'req.ssl_sni': { summary: 'Выборка: имя сервера (SNI) из TLS ClientHello без расшифровки. Требует tcp-request inspect-delay.', syntax: 'acl <имя> req.ssl_sni -i <домен>[ …]', warning: '⚠ Если клиент **не передаёт SNI** (старый клиент, IP вместо домена), то сработает default_backend.' },
+ 'req_ssl_sni': { summary: 'Устаревшее имя req.ssl_sni: SNI из TLS ClientHello.', syntax: 'req_ssl_sni -i <домен>' },
+ 'req.ssl_hello_type': { summary: 'Выборка: тип TLS handshake-сообщения; 1 — ClientHello. Используется, чтобы принять соединение, как только SNI получен.', syntax: 'tcp-request content accept if { req.ssl_hello_type 1 }' },
+ 'req.len': { summary: 'Выборка: сколько байт запроса уже получено в буфере.', syntax: 'req.len gt 0' },
+ 'req.ssl_ver': { summary: 'Выборка: версия TLS из ClientHello.', syntax: 'req.ssl_ver ge 3.3' },
+ 'ssl_fc_sni': { summary: 'Выборка: SNI при TLS-терминации на bind ssl.', syntax: 'ssl_fc_sni -i <домен>' },
+ 'sc0_bytes_out_rate': { summary: 'Скорость исходящих байт по счётчику sc0.', syntax: 'sc0_bytes_out_rate gt ' },
+ ipmask: { summary: 'Преобразователь: маскирует адрес до сети (IPv4-префикс, IPv6-префикс). src,ipmask(32,64) — привязка по /64 для IPv6.', syntax: 'src,ipmask([,])' },
+ '-i': { summary: 'Флаг ACL: сравнение без учёта регистра.', syntax: 'acl <имя> <выборка> -i <значения>' },
+ '-m': { summary: 'Флаг ACL: метод сравнения — str, beg, end, sub, reg, found, ip, int…', syntax: 'acl <имя> <выборка> -m <метод> <значения>' },
+ '-f': { summary: 'Флаг ACL: загрузить значения из файла (по одному на строку).', syntax: 'acl <имя> src -f <файл>', warning: '⚠ Если **файла нет** на ноде, то HAProxy не загрузит конфиг.' },
+ '-n': { summary: 'Флаг ACL: не резолвить DNS-имена в значениях.', syntax: 'acl <имя> src -n <значения>' },
+ enabled: { summary: 'Функция условия .if: истинна, если возможность (например SPLICE) поддерживается и не выключена.', syntax: '.if enabled(<возможность>)' },
+ defined: { summary: 'Функция условия .if: истинна, если переменная окружения определена.', syntax: '.if defined(<переменная>)' },
+ version_atleast: { summary: 'Функция условия .if: версия HAProxy не ниже указанной.', syntax: '.if version_atleast(2.8)' },
+ feature: { summary: 'Функция условия .if: сборка поддерживает возможность.', syntax: '.if feature(OPENSSL)' },
+};
+
+/** Tokens that take a value right after them (the value gets a duration/number doc). */
+export const DURATION_UNITS: Record = { us: 'микросекунд', ms: 'миллисекунд', s: 'секунд', m: 'минут', h: 'часов', d: 'дней' };
diff --git a/frontend/src/features/haproxy-editor/haproxyKeywords.json b/frontend/src/features/haproxy-editor/haproxyKeywords.json
new file mode 100644
index 0000000..6e7c39b
--- /dev/null
+++ b/frontend/src/features/haproxy-editor/haproxyKeywords.json
@@ -0,0 +1,529 @@
+{
+ "_comment": "HAProxy 2.8-3.2 keyword allowlists (+3.3 additions accepted leniently). Source: doc/configuration.txt and src/*.c keyword tables. Section letters: d=defaults, n=named defaults only, f=frontend, l=listen, b=backend. Server/bind values: number of value arguments (-1 = variable). Keep internal/panel/haproxy_keywords.json identical (TestLintKeywordsJSONInSync).",
+ "global": {
+ "keywords": ["anonkey", "busy-polling", "ca-base", "chroot", "close-spread-time", "cluster-secret", "cpu-affinity", "cpu-map", "cpu-policy", "cpu-set", "crt-base", "daemon", "debug.counters", "default-path", "description", "dns-accept-family", "expose-deprecated-directives", "expose-experimental-directives", "external-check", "fd-hard-limit", "force-cfg-parser-pause", "gid", "grace", "group", "h1-accept-payload-with-any-method", "h1-case-adjust", "h1-case-adjust-file", "h1-do-not-close-on-insecure-transfer-encoding", "h2-workaround-bogus-websocket-clients", "hard-stop-after", "http-err-codes", "http-fail-codes", "insecure-fork-wanted", "insecure-setuid-wanted", "issuers-chain-path", "key-base", "limited-quic", "localpeer", "log", "log-send-hostname", "log-tag", "lua-load", "lua-load-per-thread", "lua-prepend-path", "master-worker", "max-spread-checks", "max-threads-per-group", "maxcompcpuusage", "maxcomprate", "maxconn", "maxconnrate", "maxpipes", "maxsessrate", "maxsslconn", "maxsslrate", "maxzlibmem", "mworker-max-reloads", "nbthread", "no-memory-trimming", "no-quic", "node", "noepoll", "noevports", "nogetaddrinfo", "nokqueue", "noktls", "nopoll", "noreuseport", "nosplice", "numa-cpu-mapping", "pidfile", "pp2-never-send-local", "prealloc-fd", "presetenv", "quick-exit", "quiet", "resetenv", "server-state-base", "server-state-file", "set-dumpable", "set-var", "set-var-fmt", "setcap", "setenv", "shm-stats-file", "shm-stats-file-max-objects", "spread-checks", "stats", "stats-file", "stress-level", "strict-limits", "thread-group", "thread-groups", "thread-hard-limit", "uid", "ulimit-n", "unix-bind", "unsetenv", "user", "warn-blocked-traffic-after", "worker-id", "zero-warning"],
+ "prefixes": ["tune.", "ssl-", "httpclient.", "profiling.", "ocsp-update.", "harden.", "51degrees-", "wurfl-", "deviceatlas-", "acme.", "jwt."],
+ "negatable": ["log", "busy-polling", "set-dumpable", "strict-limits", "insecure-fork-wanted", "numa-cpu-mapping"]
+ },
+ "proxy": {
+ "acl": "nflb",
+ "backlog": "dfl",
+ "balance": "dlb",
+ "be-unpublished": "lb",
+ "bind": "fl",
+ "capture": "fl",
+ "clitcpka-cnt": "dfl",
+ "clitcpka-idle": "dfl",
+ "clitcpka-intvl": "dfl",
+ "compression": "dflb",
+ "cookie": "dlb",
+ "declare": "fl",
+ "decompression": "dflb",
+ "default-server": "dlb",
+ "default_backend": "dfl",
+ "description": "flb",
+ "disabled": "dflb",
+ "dispatch": "lb",
+ "dynamic-cookie-key": "dlb",
+ "email-alert": "dflb",
+ "enabled": "dflb",
+ "error-log-format": "dfl",
+ "errorfile": "dflb",
+ "errorfiles": "dflb",
+ "errorloc": "dflb",
+ "errorloc302": "dflb",
+ "errorloc303": "dflb",
+ "external-check": "dlb",
+ "filter": "flb",
+ "force-be-switch": "fl",
+ "force-persist": "lb",
+ "fullconn": "dlb",
+ "guid": "flb",
+ "hash-balance-factor": "dlb",
+ "hash-preserve-affinity": "dlb",
+ "hash-type": "dlb",
+ "http-after-response": "nflb",
+ "http-check": "dlb",
+ "http-error": "dflb",
+ "http-request": "nflb",
+ "http-response": "nflb",
+ "http-reuse": "dlb",
+ "http-send-name-header": "dlb",
+ "id": "flb",
+ "ignore-persist": "lb",
+ "load-server-state-from-file": "dlb",
+ "log": "dflb",
+ "log-format": "dfl",
+ "log-format-sd": "dfl",
+ "log-steps": "dfl",
+ "log-tag": "dflb",
+ "max-keep-alive-queue": "dlb",
+ "max-session-srv-conns": "dfl",
+ "maxconn": "dfl",
+ "mode": "dflb",
+ "monitor": "fl",
+ "monitor-uri": "dfl",
+ "option": "dflb",
+ "persist": "dlb",
+ "quic-initial": "nfl",
+ "rate-limit": "dfl",
+ "redirect": "flb",
+ "retries": "dlb",
+ "retry-on": "dlb",
+ "server": "lb",
+ "server-state-file-name": "dlb",
+ "server-template": "lb",
+ "source": "dlb",
+ "srvtcpka-cnt": "dlb",
+ "srvtcpka-idle": "dlb",
+ "srvtcpka-intvl": "dlb",
+ "ssl-f-use": "fl",
+ "stats": "dflb",
+ "stick": "lb",
+ "stick-table": "flb",
+ "tcp-check": "dlb",
+ "tcp-request": "nflb",
+ "tcp-response": "nlb",
+ "timeout": "dflb",
+ "transparent": "dlb",
+ "unique-id-format": "dflb",
+ "unique-id-header": "dfl",
+ "use-fcgi-app": "lb",
+ "use-server": "lb",
+ "use_backend": "fl"
+ },
+ "proxy_sub": {
+ "capture cookie": "fl",
+ "capture request": "fl",
+ "capture response": "fl",
+ "declare capture": "fl",
+ "email-alert from": "dflb",
+ "email-alert level": "dflb",
+ "email-alert mailers": "dflb",
+ "email-alert myhostname": "dflb",
+ "email-alert to": "dflb",
+ "external-check command": "dlb",
+ "external-check path": "dlb",
+ "http-check comment": "dlb",
+ "http-check connect": "dlb",
+ "http-check disable-on-404": "dlb",
+ "http-check expect": "dlb",
+ "http-check send": "dlb",
+ "http-check send-state": "dlb",
+ "http-check set-var": "dlb",
+ "http-check unset-var": "dlb",
+ "monitor fail": "fl",
+ "persist rdp-cookie": "dlb",
+ "rate-limit sessions": "dfl",
+ "stats admin": "flb",
+ "stats auth": "dflb",
+ "stats enable": "dflb",
+ "stats hide-version": "dflb",
+ "stats http-request": "flb",
+ "stats realm": "dflb",
+ "stats refresh": "dflb",
+ "stats scope": "dflb",
+ "stats show-desc": "dflb",
+ "stats show-legends": "dflb",
+ "stats show-node": "dflb",
+ "stats show-version": "dflb",
+ "stats uri": "dflb",
+ "stick match": "lb",
+ "stick on": "lb",
+ "stick store-request": "lb",
+ "stick store-response": "lb",
+ "tcp-check comment": "dlb",
+ "tcp-check connect": "dlb",
+ "tcp-check expect": "dlb",
+ "tcp-check send": "dlb",
+ "tcp-check send-binary": "dlb",
+ "tcp-check send-binary-lf": "dlb",
+ "tcp-check send-lf": "dlb",
+ "tcp-check set-var": "dlb",
+ "tcp-check unset-var": "dlb",
+ "tcp-request connection": "nfl",
+ "tcp-request content": "nflb",
+ "tcp-request inspect-delay": "nflb",
+ "tcp-request session": "nfl",
+ "tcp-response content": "nlb",
+ "tcp-response inspect-delay": "nlb",
+ "timeout check": "dlb",
+ "timeout client": "dfl",
+ "timeout client-fin": "dfl",
+ "timeout client-hs": "dfl",
+ "timeout connect": "dlb",
+ "timeout http-keep-alive": "dflb",
+ "timeout http-request": "dflb",
+ "timeout queue": "dlb",
+ "timeout server": "dlb",
+ "timeout server-fin": "dlb",
+ "timeout tarpit": "dflb",
+ "timeout tunnel": "dlb"
+ },
+ "proxy_strict_sub": ["capture", "declare", "email-alert", "external-check", "monitor", "persist", "rate-limit", "stick", "tcp-request", "tcp-response"],
+ "proxy_negatable": ["option", "log"],
+ "options": {
+ "abortonclose": "dlb",
+ "accept-invalid-http-request": "dfl",
+ "accept-invalid-http-response": "dlb",
+ "accept-unsafe-violations-in-http-request": "dfl",
+ "accept-unsafe-violations-in-http-response": "dlb",
+ "allbackups": "dlb",
+ "assume-rfc6587-ntf": "dfl",
+ "checkcache": "dlb",
+ "clitcpka": "dfl",
+ "contstats": "dfl",
+ "disable-h2-upgrade": "dfl",
+ "dont-parse-log": "dfl",
+ "dontlog-normal": "dfl",
+ "dontlognull": "dfl",
+ "external-check": "dlb",
+ "forwarded": "dlb",
+ "forwardfor": "dflb",
+ "h1-case-adjust-bogus-client": "dfl",
+ "h1-case-adjust-bogus-server": "dlb",
+ "http-buffer-request": "dflb",
+ "http-drop-request-trailers": "dlb",
+ "http-drop-response-trailers": "dfl",
+ "http-ignore-probes": "dfl",
+ "http-keep-alive": "dflb",
+ "http-no-delay": "dflb",
+ "http-pretend-keepalive": "dlb",
+ "http-restrict-req-hdr-names": "dflb",
+ "http-server-close": "dflb",
+ "http-use-proxy-header": "dfl",
+ "httpchk": "dlb",
+ "httpclose": "dflb",
+ "httplog": "dfl",
+ "httpslog": "dfl",
+ "idle-close-on-response": "dfl",
+ "independent-streams": "dflb",
+ "ldap-check": "dlb",
+ "log-health-checks": "dlb",
+ "log-separate-errors": "dfl",
+ "logasap": "dfl",
+ "mysql-check": "dlb",
+ "nolinger": "dflb",
+ "originalto": "dflb",
+ "persist": "dlb",
+ "pgsql-check": "dlb",
+ "prefer-last-server": "dlb",
+ "redis-check": "dlb",
+ "redispatch": "dlb",
+ "smtpchk": "dlb",
+ "socket-stats": "dfl",
+ "splice-auto": "dflb",
+ "splice-request": "dflb",
+ "splice-response": "dflb",
+ "spop-check": "dlb",
+ "srvtcpka": "dlb",
+ "ssl-hello-chk": "dlb",
+ "tcp-check": "dlb",
+ "tcp-smart-accept": "dfl",
+ "tcp-smart-connect": "dlb",
+ "tcpka": "dflb",
+ "tcplog": "dfl",
+ "transparent": "dlb",
+ "use-small-buffers": "dlb"
+ },
+ "options_no_negation": ["external-check", "forwardfor", "http-restrict-req-hdr-names", "httpchk", "httplog", "httpslog", "ldap-check", "mysql-check", "originalto", "pgsql-check", "redis-check", "smtpchk", "spop-check", "ssl-hello-chk", "tcp-check", "tcpka", "tcplog"],
+ "option_args": {
+ "abortonclose": "none",
+ "accept-invalid-http-request": "none",
+ "accept-invalid-http-response": "none",
+ "accept-unsafe-violations-in-http-request": "none",
+ "accept-unsafe-violations-in-http-response": "none",
+ "allbackups": "none",
+ "assume-rfc6587-ntf": "none",
+ "checkcache": "none",
+ "clitcpka": "none",
+ "contstats": "none",
+ "disable-h2-upgrade": "none",
+ "dont-parse-log": "none",
+ "dontlog-normal": "none",
+ "dontlognull": "none",
+ "h1-case-adjust-bogus-client": "none",
+ "h1-case-adjust-bogus-server": "none",
+ "http-buffer-request": "none",
+ "http-drop-request-trailers": "none",
+ "http-drop-response-trailers": "none",
+ "http-ignore-probes": "none",
+ "http-keep-alive": "none",
+ "http-no-delay": "none",
+ "http-pretend-keepalive": "none",
+ "http-server-close": "none",
+ "http-use-proxy-header": "none",
+ "httpclose": "none",
+ "httplog": "clf",
+ "idle-close-on-response": "none",
+ "independent-streams": "none",
+ "log-health-checks": "none",
+ "log-separate-errors": "none",
+ "logasap": "none",
+ "nolinger": "none",
+ "persist": "none",
+ "prefer-last-server": "none",
+ "socket-stats": "none",
+ "splice-auto": "none",
+ "splice-request": "none",
+ "splice-response": "none",
+ "srvtcpka": "none",
+ "tcp-smart-accept": "none",
+ "tcp-smart-connect": "none",
+ "tcpka": "none",
+ "tcplog": "clf",
+ "transparent": "none"
+ },
+ "server": {
+ "addr": 1,
+ "agent-addr": 1,
+ "agent-check": 0,
+ "agent-inter": 1,
+ "agent-port": 1,
+ "agent-send": 1,
+ "allow-0rtt": 0,
+ "alpn": 1,
+ "backup": 0,
+ "ca-file": 1,
+ "cc": 1,
+ "check": 0,
+ "check-alpn": 1,
+ "check-pool-conn-name": 1,
+ "check-proto": 1,
+ "check-reuse-pool": 0,
+ "check-send-proxy": 0,
+ "check-sni": 1,
+ "check-sni-auto": 0,
+ "check-ssl": 0,
+ "check-via-socks4": 0,
+ "ciphers": 1,
+ "ciphersuites": 1,
+ "client-sigalgs": 1,
+ "cookie": 1,
+ "crl-file": 1,
+ "crt": 1,
+ "curves": 1,
+ "disabled": 0,
+ "downinter": 1,
+ "enabled": 0,
+ "error-limit": 1,
+ "fall": 1,
+ "fastinter": 1,
+ "force-sslv3": 0,
+ "force-tlsv10": 0,
+ "force-tlsv11": 0,
+ "force-tlsv12": 0,
+ "force-tlsv13": 0,
+ "guid": 1,
+ "hash-key": 1,
+ "healthcheck": 1,
+ "id": 1,
+ "idle-ping": 1,
+ "init-addr": 1,
+ "init-state": 1,
+ "inter": 1,
+ "ktls": 1,
+ "log-bufsize": 1,
+ "log-proto": 1,
+ "max-reuse": 1,
+ "maxconn": 1,
+ "maxqueue": 1,
+ "minconn": 1,
+ "namespace": 1,
+ "no-agent-check": 0,
+ "no-backup": 0,
+ "no-check": 0,
+ "no-check-reuse-pool": 0,
+ "no-check-send-proxy": 0,
+ "no-check-sni-auto": 0,
+ "no-check-ssl": 0,
+ "no-renegotiate": 0,
+ "no-send-proxy": 0,
+ "no-send-proxy-v2": 0,
+ "no-send-proxy-v2-ssl": 0,
+ "no-send-proxy-v2-ssl-cn": 0,
+ "no-sni-auto": 0,
+ "no-ssl": 0,
+ "no-ssl-reuse": 0,
+ "no-sslv3": 0,
+ "no-tfo": 0,
+ "no-tls-tickets": 0,
+ "no-tlsv10": 0,
+ "no-tlsv11": 0,
+ "no-tlsv12": 0,
+ "no-tlsv13": 0,
+ "no-verifyhost": 0,
+ "non-stick": 0,
+ "npn": 1,
+ "observe": 1,
+ "on-error": 1,
+ "on-marked-down": 1,
+ "on-marked-up": 1,
+ "pool-conn-name": 1,
+ "pool-low-conn": 1,
+ "pool-max-conn": 1,
+ "pool-purge-delay": 1,
+ "port": 1,
+ "proto": 1,
+ "proxy-v2-options": 1,
+ "quic-cc-algo": 1,
+ "redir": 1,
+ "renegotiate": 0,
+ "resolve-net": 1,
+ "resolve-opts": 1,
+ "resolve-prefer": 1,
+ "resolvers": 1,
+ "rise": 1,
+ "send-proxy": 0,
+ "send-proxy-v2": 0,
+ "send-proxy-v2-ssl": 0,
+ "send-proxy-v2-ssl-cn": 0,
+ "set-proxy-v2-tlv-fmt": 1,
+ "shard": 1,
+ "sigalgs": 1,
+ "slowstart": 1,
+ "sni": 1,
+ "sni-auto": 0,
+ "socks4": 1,
+ "source": -1,
+ "ssl": 0,
+ "ssl-max-ver": 1,
+ "ssl-min-ver": 1,
+ "ssl-reuse": 0,
+ "stick": 0,
+ "strict-maxconn": 0,
+ "tcp-md5sig": 1,
+ "tcp-ut": 1,
+ "tfo": 0,
+ "tls-tickets": 0,
+ "track": 1,
+ "usesrc": 1,
+ "verify": 1,
+ "verifyhost": 1,
+ "weight": 1,
+ "ws": 1
+ },
+ "bind": {
+ "accept-netscaler-cip": 1,
+ "accept-proxy": 0,
+ "allow-0rtt": 0,
+ "alpn": 1,
+ "backlog": 1,
+ "ca-file": 1,
+ "ca-ignore-err": 1,
+ "ca-sign-file": 1,
+ "ca-sign-pass": 1,
+ "ca-verify-file": 1,
+ "cc": 1,
+ "ciphers": 1,
+ "ciphersuites": 1,
+ "client-sigalgs": 1,
+ "crl-file": 1,
+ "crt": 1,
+ "crt-ignore-err": 1,
+ "crt-list": 1,
+ "curves": 1,
+ "default-crt": 1,
+ "defer-accept": 0,
+ "ecdhe": 1,
+ "ech": 1,
+ "expose-fd": 1,
+ "force-sslv3": 0,
+ "force-tlsv10": 0,
+ "force-tlsv11": 0,
+ "force-tlsv12": 0,
+ "force-tlsv13": 0,
+ "generate-certificates": 0,
+ "gid": 1,
+ "group": 1,
+ "guid-prefix": 1,
+ "id": 1,
+ "idle-ping": 1,
+ "interface": 1,
+ "ktls": 1,
+ "label": 1,
+ "level": 1,
+ "maxconn": 1,
+ "mode": 1,
+ "mss": 1,
+ "name": 1,
+ "namespace": 1,
+ "nbconn": 1,
+ "nice": 1,
+ "no-alpn": 0,
+ "no-ca-names": 0,
+ "no-sslv3": 0,
+ "no-strict-sni": 0,
+ "no-tls-tickets": 0,
+ "no-tlsv10": 0,
+ "no-tlsv11": 0,
+ "no-tlsv12": 0,
+ "no-tlsv13": 0,
+ "npn": 1,
+ "ocsp-update": 1,
+ "prefer-client-ciphers": 0,
+ "process": 1,
+ "proto": 1,
+ "quic-cc-algo": 1,
+ "quic-force-retry": 0,
+ "quic-socket": 1,
+ "severity-output": 1,
+ "shards": 1,
+ "sigalgs": 1,
+ "ssl": 0,
+ "ssl-max-ver": 1,
+ "ssl-min-ver": 1,
+ "strict-sni": 0,
+ "tcp-md5sig": 1,
+ "tcp-ss": 1,
+ "tcp-ut": 1,
+ "tfo": 0,
+ "thread": 1,
+ "tls-ticket-keys": 1,
+ "tls-tickets": 0,
+ "transparent": 0,
+ "uid": 1,
+ "user": 1,
+ "v4v6": 0,
+ "v6only": 0,
+ "verify": 1
+ },
+ "timeouts": {
+ "proxy": ["check", "client", "client-fin", "client-hs", "connect", "http-keep-alive", "http-request", "queue", "server", "server-fin", "tarpit", "tunnel"],
+ "resolvers": ["resolve", "retry"],
+ "ring": ["connect", "server"],
+ "log-forward": ["client"],
+ "mailers": ["mail"]
+ },
+ "modes": {
+ "tcp": "dflb",
+ "http": "dflb",
+ "log": "dlb",
+ "spop": "dlb"
+ },
+ "balance": ["first", "hash", "hdr", "leastconn", "log-hash", "random", "rdp-cookie", "roundrobin", "source", "static-rr", "sticky", "uri", "url_param"],
+ "hash_type": {
+ "methods": ["consistent", "map-based"],
+ "functions": ["crc32", "djb2", "none", "sdbm", "wt6"],
+ "modifiers": ["avalanche"]
+ },
+ "log": {
+ "facilities": ["kern", "user", "mail", "daemon", "auth", "syslog", "lpr", "news", "uucp", "cron", "auth2", "ftp", "ntp", "audit", "alert", "cron2", "local0", "local1", "local2", "local3", "local4", "local5", "local6", "local7"],
+ "levels": ["emerg", "alert", "crit", "err", "warning", "notice", "info", "debug"],
+ "options": ["format", "len", "profile", "sample"]
+ },
+ "other_sections": {
+ "resolvers": ["accepted_payload_size", "hold", "nameserver", "parse-resolv-conf", "resolution_pool_size", "resolve_retries", "timeout"],
+ "peers": ["bind", "default-bind", "default-server", "disabled", "enable", "enabled", "log", "peer", "server", "shards", "table"],
+ "userlist": ["group", "user"],
+ "cache": ["max-age", "max-object-size", "max-secondary-entries", "process-vary", "total-max-size"],
+ "program": ["command", "group", "no", "option", "user"],
+ "http-errors": ["errorfile"],
+ "ring": ["backing-file", "description", "format", "maxlen", "server", "size", "timeout"],
+ "log-forward": ["backlog", "bind", "dgram-bind", "log", "maxconn", "no", "option", "timeout"],
+ "mailers": ["mailer", "timeout"],
+ "crt-store": ["crt-base", "key-base", "load"],
+ "traces": ["trace"],
+ "acme": ["account-key", "bits", "challenge", "contact", "curves", "directory", "keytype", "map"]
+ }
+}
diff --git a/frontend/src/features/haproxy-editor/haproxyLintRules.ts b/frontend/src/features/haproxy-editor/haproxyLintRules.ts
new file mode 100644
index 0000000..2fa2b4f
--- /dev/null
+++ b/frontend/src/features/haproxy-editor/haproxyLintRules.ts
@@ -0,0 +1,268 @@
+// Keyword-level checks of the offline linter. Mirrors
+// internal/panel/haproxy_lint_checks.go: both read haproxyKeywords.json (the Go
+// side embeds a byte-identical copy) and must report the same codes.
+import KEYWORDS from './haproxyKeywords.json' with { type: 'json' };
+
+export interface RuleWord { text: string; start: number; end: number; quoted: boolean }
+export type RuleEmit = (word: RuleWord, code: string, message: string, severity?: 'error' | 'warning') => void;
+
+interface KeywordData {
+ global: { keywords: string[]; prefixes: string[]; negatable: string[] };
+ proxy: Record;
+ proxy_sub: Record;
+ proxy_strict_sub: string[];
+ proxy_negatable: string[];
+ options: Record;
+ options_no_negation: string[];
+ option_args: Record;
+ server: Record;
+ bind: Record;
+ timeouts: Record;
+ modes: Record;
+ balance: string[];
+ hash_type: { methods: string[]; functions: string[]; modifiers: string[] };
+ log: { facilities: string[]; levels: string[]; options: string[] };
+ other_sections: Record;
+}
+
+const K = KEYWORDS as unknown as KeywordData;
+const set = (words: string[]) => new Set(words);
+const GLOBAL = set(K.global.keywords);
+const GLOBAL_NEGATABLE = set(K.global.negatable);
+const STRICT_SUB = set(K.proxy_strict_sub);
+const OPTIONS_NO_NEG = set(K.options_no_negation);
+const TIMEOUTS = Object.fromEntries(Object.entries(K.timeouts).map(([k, v]) => [k, set(v)]));
+const OTHER = Object.fromEntries(Object.entries(K.other_sections).map(([k, v]) => [k, set(v)]));
+const BALANCE = set(K.balance);
+const HASH = [set(K.hash_type.methods), set(K.hash_type.functions), set(K.hash_type.modifiers)];
+const LOG_FACILITIES = set(K.log.facilities);
+const LOG_LEVELS = set(K.log.levels);
+const LOG_OPTIONS = set(K.log.options);
+const DURATION_RE = /^\d+(us|ms|s|m|h|d)?$/;
+const SERVER_DURATION = set(['inter', 'fastinter', 'downinter', 'slowstart', 'agent-inter', 'pool-purge-delay']);
+const FREE_TEXT = set([
+ 'acl', 'bind', 'capture', 'description', 'email-alert', 'error-log-format', 'errorfile', 'errorfiles',
+ 'errorloc', 'errorloc302', 'errorloc303', 'filter', 'http-after-response', 'http-check', 'http-error',
+ 'http-request', 'http-response', 'log-format', 'log-format-sd', 'log-tag', 'monitor-uri', 'node',
+ 'presetenv', 'redirect', 'set-var', 'set-var-fmt', 'setenv', 'stats', 'tcp-check', 'tcp-request',
+ 'tcp-response', 'unique-id-format', 'unique-id-header', 'use_backend', 'default_backend', 'use-server',
+ 'stick', 'balance', 'command', 'user', 'group', 'lua-load', 'lua-load-per-thread', 'lua-prepend-path',
+ 'hash-type', 'server', 'server-template', 'default-server', 'option', 'timeout', 'mode', 'log',
+]);
+
+/** Sections the linter knows; hover docs only cover SECTION_KEYWORDS. */
+export const LINT_SECTIONS = new Set(['global', 'defaults', 'frontend', 'backend', 'listen', 'resolvers', 'peers', 'userlist', 'cache', 'program', 'http-errors', 'ring', 'log-forward', 'mailers', 'crt-store', 'traces', 'acme']);
+
+const has = (o: Record, k: string) => Object.prototype.hasOwnProperty.call(o, k);
+
+export const isProxySection = (section: string) => section === 'defaults' || section === 'frontend' || section === 'backend' || section === 'listen';
+
+export function knownKeyword(section: string, kw: string): boolean {
+ if (section === 'global') return GLOBAL.has(kw) || K.global.prefixes.some((p) => kw.startsWith(p));
+ if (isProxySection(section)) return has(K.proxy, kw);
+ return OTHER[section]?.has(kw) ?? false;
+}
+
+/** Section letters: d=defaults, n=named defaults only, f=frontend, l=listen, b=backend. */
+function sectionAllowed(flags: string, section: string, namedDefaults: boolean): { allowed: boolean; anonymous: boolean } {
+ switch (section) {
+ case 'defaults':
+ if (flags.includes('d')) return { allowed: true, anonymous: false };
+ if (flags.includes('n')) return { allowed: namedDefaults, anonymous: !namedDefaults };
+ return { allowed: false, anonymous: false };
+ case 'frontend': return { allowed: flags.includes('f'), anonymous: false };
+ case 'listen': return { allowed: flags.includes('l'), anonymous: false };
+ case 'backend': return { allowed: flags.includes('b'), anonymous: false };
+ }
+ return { allowed: true, anonymous: false };
+}
+
+export function trailingPunct(text: string): [string, number] {
+ const base = text.replace(/[,;:.]+$/, '');
+ return [base, text.length - base.length];
+}
+
+/** Narrows a word to its trailing punctuation (marker range). */
+export function punctWord(w: RuleWord, n: number): RuleWord {
+ if (w.quoted || n === 0) return w;
+ return { ...w, start: w.end - n };
+}
+
+const stray = (emit: RuleEmit, w: RuleWord, base: string, n: number) => emit(punctWord(w, n), 'stray_punctuation', `Лишний символ «${w.text.slice(base.length)}» после «${base}»`);
+
+export function negatable(section: string, kw: string): boolean {
+ return section === 'global' ? GLOBAL_NEGATABLE.has(kw) : kw === 'option' || kw === 'log';
+}
+
+export function unknownDirective(section: string, kw: RuleWord, emit: RuleEmit): void {
+ const [base, n] = trailingPunct(kw.text);
+ if (n > 0 && knownKeyword(section, base)) { stray(emit, kw, base, n); return; }
+ emit(kw, 'unknown_directive', `Неизвестная директива «${kw.text}» в секции ${section}`, section === 'global' || isProxySection(section) ? 'error' : 'warning');
+}
+
+function sectionAllows(section: string, namedDefaults: boolean, w: RuleWord, name: string, flags: string, emit: RuleEmit): boolean {
+ const { allowed, anonymous } = sectionAllowed(flags, section, namedDefaults);
+ if (allowed) return true;
+ emit(w, 'keyword_not_allowed', anonymous ? `«${name}» недопустим в безымянной секции defaults (задайте ей имя)` : `«${name}» недопустим в секции ${section}`);
+ return false;
+}
+
+/** HAProxy 4.1 keyword matrix; false when the keyword is refused here. */
+export function proxyKeywordAllowed(section: string, namedDefaults: boolean, words: RuleWord[], emit: RuleEmit): boolean {
+ const kw = words[0];
+ if (!sectionAllows(section, namedDefaults, kw, kw.text, K.proxy[kw.text] ?? '', emit)) return false;
+ if (kw.text === 'timeout' || kw.text === 'option' || words.length < 2) return true;
+ const sub = words[1];
+ const key = `${kw.text} ${sub.text}`;
+ if (has(K.proxy_sub, key)) return sectionAllows(section, namedDefaults, sub, key, K.proxy_sub[key], emit);
+ if (!STRICT_SUB.has(kw.text)) return true;
+ const [base, n] = trailingPunct(sub.text);
+ if (n > 0 && has(K.proxy_sub, `${kw.text} ${base}`)) { stray(emit, sub, base, n); return false; }
+ emit(sub, 'unknown_keyword', `Неизвестный параметр «${sub.text}» у ${kw.text}`);
+ return false;
+}
+
+export function checkTimeoutKind(section: string, words: RuleWord[], emit: RuleEmit): boolean {
+ if (words.length < 2) return true;
+ const cls = isProxySection(section) ? 'proxy' : section;
+ const kinds = TIMEOUTS[cls];
+ const kind = words[1];
+ if (kinds && !kinds.has(kind.text)) {
+ const [base, n] = trailingPunct(kind.text);
+ if (n > 0 && kinds.has(base)) stray(emit, kind, base, n);
+ else emit(kind, 'unknown_timeout', `Неизвестный тип timeout «${kind.text}»`);
+ return false;
+ }
+ if (cls === 'proxy') {
+ const flags = K.proxy_sub[`timeout ${kind.text}`];
+ if (flags && !sectionAllowed(flags, section, true).allowed) emit(kind, 'timeout_ignored', `timeout ${kind.text} игнорируется в секции ${section}`, 'warning');
+ }
+ return true;
+}
+
+export function checkOption(section: string, words: RuleWord[], modifier: string, emit: RuleEmit): void {
+ if (words.length < 2) { emit(words[0], 'option_missing_name', 'У option не указано имя'); return; }
+ const name = words[1];
+ if (!has(K.options, name.text)) {
+ const [base, n] = trailingPunct(name.text);
+ if (n > 0 && has(K.options, base)) emit(punctWord(name, n), 'unknown_option', `Неизвестная опция «${name.text}» — лишний символ «${name.text.slice(base.length)}»`);
+ else emit(name, 'unknown_option', `Неизвестная опция «${name.text}»`);
+ return;
+ }
+ if (modifier && OPTIONS_NO_NEG.has(name.text)) { emit(name, 'negation_not_supported', `Префикс «${modifier}» не поддерживается для option ${name.text}`); return; }
+ if (!sectionAllowed(K.options[name.text], section, true).allowed) emit(name, 'option_ignored', `option ${name.text} игнорируется в секции ${section}`, 'warning');
+ const args = K.option_args[name.text];
+ if (args === 'none' && words.length > 2) emit(words[2], 'too_many_args', `option ${name.text} не принимает аргументов`);
+ if (args === 'clf') {
+ if (words.length > 2 && words[2].text !== 'clf') emit(words[2], 'invalid_option_arg', `option ${name.text} принимает только «clf»`);
+ else if (words.length > 3) emit(words[3], 'too_many_args', `Лишний аргумент option ${name.text}`);
+ }
+}
+
+export function checkMode(section: string, words: RuleWord[], emit: RuleEmit): void {
+ if (words.length < 2) { emit(words[0], 'invalid_mode', 'У mode не указан режим (tcp или http)'); return; }
+ const value = words[1];
+ if (value.text === 'health') { emit(value, 'invalid_mode', 'mode health больше не поддерживается'); return; }
+ if (!has(K.modes, value.text)) {
+ const [base, n] = trailingPunct(value.text);
+ if (n > 0 && has(K.modes, base)) stray(emit, value, base, n);
+ else emit(value, 'invalid_mode', `Неизвестный режим «${value.text}» (tcp, http, log, spop)`);
+ return;
+ }
+ if (!sectionAllowed(K.modes[value.text], section, true).allowed) emit(value, 'invalid_mode', `mode ${value.text} недопустим в секции ${section}`);
+ else if (words.length > 2) emit(words[2], 'too_many_args', 'Лишний аргумент mode');
+}
+
+export function checkBalance(words: RuleWord[], emit: RuleEmit): void {
+ if (words.length < 2) { emit(words[0], 'invalid_balance', 'У balance не указан алгоритм'); return; }
+ const algo = words[1];
+ if (BALANCE.has(algo.text.split('(')[0])) return;
+ const [base, n] = trailingPunct(algo.text);
+ if (n > 0 && BALANCE.has(base)) stray(emit, algo, base, n);
+ else emit(algo, 'invalid_balance', `Неизвестный алгоритм balance «${algo.text}»`);
+}
+
+export function checkHashType(words: RuleWord[], emit: RuleEmit): void {
+ if (words.length < 2) { emit(words[0], 'invalid_hash_type', 'hash-type: укажите consistent или map-based'); return; }
+ for (let i = 1; i < words.length; i++) {
+ const w = words[i];
+ const allowed = HASH[i - 1];
+ if (!allowed) { emit(w, 'too_many_args', 'Лишний аргумент hash-type'); return; }
+ if (allowed.has(w.text)) continue;
+ const [base, n] = trailingPunct(w.text);
+ if (n > 0 && allowed.has(base)) stray(emit, w, base, n);
+ else emit(w, 'invalid_hash_type', `Неверный аргумент hash-type «${w.text}»`);
+ return;
+ }
+}
+
+export function checkLog(section: string, words: RuleWord[], emit: RuleEmit): void {
+ if (words.length < 2) { emit(words[0], 'log_missing_target', 'У log не указан адрес'); return; }
+ if (words[1].text === 'global' && section !== 'global') {
+ if (words.length > 2) emit(words[2], 'too_many_args', 'Лишний аргумент log global');
+ return;
+ }
+ let i = 2;
+ while (i < words.length && LOG_OPTIONS.has(words[i].text)) i += 2;
+ if (i >= words.length) { emit(words[words.length - 1], 'log_missing_facility', 'У log не указан facility (например local0)'); return; }
+ for (let n = 0; i + n < words.length; n++) {
+ const w = words[i + n];
+ if (n > 2) { emit(w, 'too_many_args', `Лишний аргумент log «${w.text}»`); return; }
+ const allowed = n === 0 ? LOG_FACILITIES : LOG_LEVELS;
+ if (w.text.includes('$') || allowed.has(w.text)) continue;
+ const [base, cut] = trailingPunct(w.text);
+ if (cut > 0 && allowed.has(base)) stray(emit, w, base, cut);
+ else emit(w, 'invalid_log', `Неизвестный ${n === 0 ? 'facility' : 'уровень'} log «${w.text}»`);
+ return;
+ }
+}
+
+function keywordArgs(words: RuleWord[], start: number, table: Record, code: string, what: string, emit: RuleEmit, check?: (kw: string, value: RuleWord) => void): void {
+ for (let i = start; i < words.length;) {
+ const w = words[i];
+ const name = w.text.split('(')[0];
+ if (!has(table, name)) {
+ const [base, cut] = trailingPunct(w.text);
+ if (cut > 0 && has(table, base)) stray(emit, w, base, cut);
+ else emit(w, code, `Неизвестный параметр ${what} «${w.text}»`);
+ i++;
+ continue;
+ }
+ const n = table[name];
+ if (n < 0) {
+ i += 2;
+ while (i + 1 < words.length && (words[i].text === 'usesrc' || words[i].text === 'interface')) i += 2;
+ continue;
+ }
+ if (n > 0 && i + n >= words.length) { emit(w, 'missing_value', `У параметра «${w.text}» нет значения`); return; }
+ if (n > 0 && check) check(name, words[i + 1]);
+ i += 1 + n;
+ }
+}
+
+export function checkServerKeywords(words: RuleWord[], start: number, emit: RuleEmit): void {
+ keywordArgs(words, start, K.server, 'unknown_server_keyword', 'server', emit, (kw, value) => {
+ if (value.text.includes('$')) return;
+ if (SERVER_DURATION.has(kw)) {
+ if (!DURATION_RE.test(value.text)) emit(value, 'invalid_duration', `«${value.text}» — неверное время для ${kw} (пример: 5s, 500ms)`);
+ } else if (kw === 'fall' || kw === 'rise') {
+ if (!/^[1-9]\d*$/.test(value.text)) emit(value, 'invalid_number', `${kw} ожидает целое число ≥ 1`);
+ } else if (kw === 'weight') {
+ if (!/^\d+$/.test(value.text) || Number(value.text) > 256) emit(value, 'invalid_weight', 'weight — целое число 0–256');
+ }
+ });
+}
+
+export function checkBindKeywords(words: RuleWord[], start: number, emit: RuleEmit): void {
+ keywordArgs(words, start, K.bind, 'unknown_bind_keyword', 'bind', emit);
+}
+
+/** Bare trailing ',' or ';' after a plain-value directive (maxconn 100;). Returns the marker word or null. */
+export function strayTail(words: RuleWord[]): { word: RuleWord; punct: string } | null {
+ if (FREE_TEXT.has(words[0].text) || words.length < 2) return null;
+ const last = words[words.length - 1];
+ if (last.quoted || last.text.includes('%[') || /[{}]/.test(last.text)) return null;
+ const base = last.text.replace(/[,;]+$/, '');
+ if (base === last.text) return null;
+ return { word: punctWord(last, last.text.length - base.length), punct: last.text.slice(base.length) };
+}
diff --git a/frontend/src/features/haproxy-editor/haproxyModel.ts b/frontend/src/features/haproxy-editor/haproxyModel.ts
new file mode 100644
index 0000000..3230d23
--- /dev/null
+++ b/frontend/src/features/haproxy-editor/haproxyModel.ts
@@ -0,0 +1,401 @@
+// Pure haproxy.cfg helpers shared by the Monaco editor and node --test:
+// tokenizer, hover resolution, client-side formatter and fallback linter.
+// Only type-free runtime imports with explicit .ts extensions are allowed here.
+import {
+ argDocs, directiveDocs, DURATION_UNITS, GENERATED_NAME_DOC, keywordDocs, SECTION_KEYWORDS, sectionDocs,
+ type HAProxyDoc,
+} from './haproxyDocs.ts';
+import {
+ checkBalance, checkBindKeywords, checkHashType, checkLog, checkMode, checkOption, checkServerKeywords, checkTimeoutKind,
+ isProxySection, knownKeyword, LINT_SECTIONS, negatable, proxyKeywordAllowed, punctWord, strayTail, trailingPunct, unknownDirective,
+ type RuleEmit, type RuleWord,
+} from './haproxyLintRules.ts';
+
+export interface LineToken { text: string; start: number; end: number }
+
+export type LintSeverity = 'error' | 'warning';
+export interface LintIssue { line: number; column: number; end_column: number; severity: LintSeverity; code: string; message: string }
+export interface LintSection { type: string; name: string; line: number }
+export interface LintResult { valid: boolean; issues: LintIssue[]; listener_tcp_ports: number[]; sections: LintSection[] }
+
+export const MAX_CONFIG_BYTES = 512 * 1024;
+const SECTION_SET = new Set(SECTION_KEYWORDS);
+const NAMED_SECTIONS = new Set(['frontend', 'backend', 'listen', 'resolvers', 'peers', 'userlist', 'cache', 'program', 'ring', 'http-errors', 'mailers', 'log-forward']);
+const PROXY_SECTIONS = new Set(['frontend', 'backend', 'listen', 'defaults']);
+const CONDITIONALS = new Set(['.if', '.elif', '.else', '.endif', '.notice', '.warning', '.alert', '.diag']);
+const DURATION_RE = /^\d+(us|ms|s|m|h|d)?$/;
+const GENERATED_RE = /^nf_[a-z0-9_]+$/i;
+
+/** Splits one line into whitespace-separated words; stops at an unquoted #. */
+export function tokenizeLine(line: string): LineToken[] {
+ const tokens: LineToken[] = [];
+ let i = 0;
+ while (i < line.length) {
+ while (i < line.length && /\s/.test(line[i])) i++;
+ if (i >= line.length || line[i] === '#') break;
+ const start = i;
+ let quote = '';
+ while (i < line.length) {
+ const ch = line[i];
+ if (quote) {
+ if (ch === '\\' && quote === '"') { i += 2; continue; }
+ if (ch === quote) quote = '';
+ i++; continue;
+ }
+ if (ch === '"' || ch === "'") { quote = ch; i++; continue; }
+ if (ch === '\\') { i += 2; continue; }
+ if (/\s/.test(ch)) break;
+ i++;
+ }
+ tokens.push({ text: line.slice(start, Math.min(i, line.length)), start, end: Math.min(i, line.length) });
+ }
+ return tokens;
+}
+
+function sectionAt(lines: string[], index: number): { type: string; line: number } | null {
+ for (let i = index; i >= 0; i--) {
+ const first = tokenizeLine(lines[i])[0]?.text.toLowerCase();
+ if (first && SECTION_SET.has(first)) return { type: first, line: i };
+ }
+ return null;
+}
+
+/** Longest known directive phrase at the start of a line («timeout connect», «option tcplog», «maxconn»). */
+export function directiveKey(words: string[]): { key: string; length: number } | null {
+ const lower = words.map((w) => w.toLowerCase());
+ for (let length = Math.min(3, lower.length); length >= 1; length--) {
+ const key = lower.slice(0, length).join(' ');
+ if (directiveDocs[key]) return { key, length };
+ }
+ if (lower[0] === 'no' && lower[1] === 'option') return { key: 'no option', length: 2 };
+ return null;
+}
+
+export type HoverKind = 'section' | 'section-name' | 'directive' | 'argument' | 'keyword' | 'value' | 'generated' | 'fallback';
+export interface HoverResult {
+ title: string;
+ kind: HoverKind;
+ doc: HAProxyDoc;
+ /** Extra first line, e.g. «Значение `15m` = 15 минут». */
+ value?: string;
+ /** Section the hovered line belongs to. */
+ section?: string;
+ start: number;
+ end: number;
+}
+
+function describeValue(word: string): string | null {
+ const duration = /^(\d+)(us|ms|s|m|h|d)$/i.exec(word);
+ if (duration) return `Значение \`${word}\` = ${duration[1]} ${DURATION_UNITS[duration[2].toLowerCase()]}`;
+ if (/^\d+$/.test(word)) return `Значение \`${word}\``;
+ const size = /^(\d+)([kmg])$/i.exec(word);
+ if (size) return `Значение \`${word}\` = ${size[1]} ${{ k: 'тысяч', m: 'миллионов', g: 'миллиардов' }[size[2].toLowerCase() as 'k' | 'm' | 'g']}`;
+ if (/^(unix@|abns@)?\//.test(word)) return `Путь \`${word}\``;
+ const address = parseAddress(word);
+ if (address) return address.port ? `Адрес \`${address.host || '*'}\`, порт ${address.port}` : `Адрес \`${address.host}\``;
+ return null;
+}
+
+function parseAddress(word: string): { host: string; port: string } | null {
+ const plain = word.replace(/^(ipv4|ipv6|tcp|tcp4|tcp6)@/i, '');
+ const bracket = /^\[([0-9a-f:.]+)\](?::([\d-]+))?$/i.exec(plain);
+ if (bracket) return { host: bracket[1], port: bracket[2] ?? '' };
+ const v4 = /^(\d{1,3}(?:\.\d{1,3}){3}|\*)?(?:\/\d+)?(?::([\d-]+))?$/.exec(plain);
+ if (v4 && (v4[1] || v4[2])) return { host: v4[1] ?? '', port: v4[2] ?? '' };
+ if (/^[0-9a-f]*:[0-9a-f:]*:[0-9a-f.:]*$/i.test(plain)) {
+ const idx = plain.lastIndexOf(':');
+ return { host: plain.slice(0, idx), port: plain.slice(idx + 1) };
+ }
+ return null;
+}
+
+/** Splits `src,ipmask(32,64)` into its function/argument parts (addresses are kept whole). */
+function subParts(token: LineToken): LineToken[] {
+ if (!/[,()]/.test(token.text) || /^["']/.test(token.text)) return [token];
+ const parts: LineToken[] = [];
+ const re = /[^,()]+/g;
+ let match: RegExpExecArray | null;
+ while ((match = re.exec(token.text))) parts.push({ text: match[0], start: token.start + match.index, end: token.start + match.index + match[0].length });
+ return parts.length ? parts : [token];
+}
+
+/** Resolves the documentation shown for the character at (lineIndex, column), both 0-based. */
+export function resolveHover(lines: string[], lineIndex: number, column: number): HoverResult | null {
+ const line = lines[lineIndex] ?? '';
+ const tokens = tokenizeLine(line);
+ const index = tokens.findIndex((t) => column >= t.start && column < t.end);
+ if (index < 0) return null;
+ const words = tokens.map((t) => t.text);
+ const lower = words.map((w) => w.toLowerCase());
+ const token = tokens[index];
+ const section = sectionAt(lines, lineIndex)?.type;
+
+ if (SECTION_SET.has(lower[0])) {
+ if (index === 0) return { title: lower[0], kind: 'section', doc: sectionDocs[lower[0]], start: token.start, end: token.end };
+ if (index === 1) {
+ if (GENERATED_RE.test(words[1])) return { title: words[1], kind: 'generated', doc: GENERATED_NAME_DOC, section: lower[0], start: token.start, end: token.end };
+ return { title: words[1], kind: 'section-name', doc: { summary: `Имя секции ${lower[0]}. На него ссылаются use_backend, default_backend и статистика.`, syntax: sectionDocs[lower[0]].syntax }, section: lower[0], start: token.start, end: token.end };
+ }
+ }
+
+ const directive = directiveKey(words);
+ if (directive && index < directive.length) {
+ const t0 = tokens[0];
+ const tl = tokens[directive.length - 1];
+ return { title: directive.key, kind: 'directive', doc: directiveDocs[directive.key], section, start: t0.start, end: tl.end };
+ }
+
+ const parts = subParts(token);
+ const part = parts.find((p) => column >= p.start && column < p.end) ?? token;
+ const word = part.text.toLowerCase();
+ const prev = index > 0 ? lower[index - 1] : '';
+ const partIndex = parts.indexOf(part);
+ const range = { start: part.start, end: part.end };
+
+ if (GENERATED_RE.test(part.text)) return { title: part.text, kind: 'generated', doc: GENERATED_NAME_DOC, section, ...range };
+ if (keywordDocs[word] && !(directive && argDocs[directive.key]?.[word]) && !argDocs[lower[0]]?.[word]) {
+ return { title: word, kind: 'keyword', doc: keywordDocs[word], section, ...range };
+ }
+ // Value of an option that owns its own vocabulary: init-addr last,none / hash-key addr.
+ if (argDocs[prev]?.[word]) return { title: word, kind: 'argument', doc: argDocs[prev][word], section, value: `Значение опции \`${prev}\``, ...range };
+ const ownerDocs = (directive && argDocs[directive.key]) || argDocs[lower[0]];
+ if (ownerDocs?.[word] && (partIndex === 0 || lower[0] === 'balance' || lower[0] === 'stick-table')) {
+ return { title: word, kind: 'argument', doc: ownerDocs[word], section, ...range };
+ }
+ if (keywordDocs[word]) return { title: word, kind: 'keyword', doc: keywordDocs[word], section, ...range };
+
+ const valueLine = describeValue(part.text) ?? describeValue(token.text);
+ const ownerKey = directive?.key ?? lower[0];
+ if (valueLine) {
+ const optionDoc = ownerDocs?.[prev];
+ if (optionDoc) return { title: prev, kind: 'value', doc: optionDoc, value: valueLine, section, ...range };
+ const doc = directiveDocs[ownerKey];
+ if (doc) return { title: ownerKey, kind: 'value', doc, value: valueLine, section, ...range };
+ return null;
+ }
+ const doc = directive ? directiveDocs[directive.key] : undefined;
+ if (doc) return { title: directive!.key, kind: 'fallback', doc, value: `Аргумент \`${part.text}\``, section, ...range };
+ return null;
+}
+
+const KIND_LABEL: Record = {
+ section: 'секция', 'section-name': 'имя секции', directive: 'директива', argument: 'опция', keyword: 'выражение', value: 'значение', generated: 'имя NodeFlow', fallback: 'аргумент',
+};
+
+export function hoverMarkdown(result: HoverResult): string {
+ const context = result.section && result.kind !== 'section' ? ` · ${result.section}` : '';
+ const out = [`**\`${result.title}\`** — ${KIND_LABEL[result.kind]}${context}`];
+ if (result.value) out.push(result.value);
+ out.push(result.doc.summary);
+ if (result.doc.syntax) out.push('```haproxy\n' + result.doc.syntax + '\n```');
+ if (result.doc.warning) out.push(result.doc.warning);
+ return out.join('\n\n');
+}
+
+/** Section keywords at column 0, everything else 4 spaces (+4 per .if level), no trailing spaces, max one blank line. */
+export function formatConfig(text: string): string {
+ const out: string[] = [];
+ let inSection = false;
+ let depth = 0;
+ let blank = false;
+ for (const raw of text.replace(/\r\n?/g, '\n').split('\n')) {
+ const body = raw.trim();
+ if (!body) { blank = out.length > 0; continue; }
+ if (blank) { out.push(''); blank = false; }
+ const first = tokenizeLine(body)[0]?.text.toLowerCase() ?? '';
+ const base = inSection ? 1 : 0;
+ if (SECTION_SET.has(first)) {
+ inSection = true; depth = 0;
+ out.push(body);
+ continue;
+ }
+ if (first === '.endif' || first === '.else' || first === '.elif') {
+ if (first === '.endif') depth = Math.max(0, depth - 1);
+ out.push(' '.repeat((base + Math.max(0, depth - (first === '.endif' ? 0 : 1))) * 4) + body);
+ continue;
+ }
+ out.push(' '.repeat((base + depth) * 4) + body);
+ if (first === '.if') depth++;
+ }
+ return out.length ? out.join('\n') + '\n' : '';
+}
+
+function issue(list: LintIssue[], line: number, token: LineToken | undefined, lineText: string, severity: LintSeverity, code: string, message: string) {
+ const start = token ? token.start : lineText.length - lineText.trimStart().length;
+ const end = token ? token.end : Math.max(start + 1, lineText.trimEnd().length);
+ list.push({ line, column: start + 1, end_column: end + 1, severity, code, message });
+}
+
+function validPort(value: string): boolean {
+ const range = /^(\d+)(?:-(\d+))?$/.exec(value);
+ if (!range) return false;
+ const low = Number(range[1]); const high = Number(range[2] ?? range[1]);
+ return low >= 1 && high <= 65535 && low <= high;
+}
+
+/** Token with HAProxy quoting removed (the raw range stays for markers). */
+function ruleWord(t: LineToken): RuleWord {
+ const quoted = /["'\\]/.test(t.text);
+ const text = quoted ? t.text.replace(/\\(.)|["']/g, (_m, ch: string | undefined) => ch ?? '') : t.text;
+ return { text, start: t.start, end: t.end, quoted };
+}
+
+/** Offline linter used when POST /config-lint is unavailable. */
+export function lintConfig(text: string): LintResult {
+ const issues: LintIssue[] = [];
+ const sections: LintSection[] = [];
+ const ports = new Set();
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
+ const proxyNames = new Map();
+ const backendRefs: { name: string; line: number; token: LineToken; text: string }[] = [];
+ const ifStack: number[] = [];
+ let current: { type: string; name: string; line: number; binds: number } | null = null;
+ let namedDefaults = false;
+ const finishSection = () => {
+ if (current && (current.type === 'frontend' || current.type === 'listen') && current.binds === 0) {
+ issues.push({ line: current.line, column: 1, end_column: current.type.length + 1, severity: 'warning', code: 'no_bind', message: `В ${current.type} «${current.name}» нет ни одного bind — секция не принимает соединения.` });
+ }
+ };
+ if (new TextEncoder().encode(text).length > MAX_CONFIG_BYTES) {
+ issues.push({ line: 1, column: 1, end_column: 2, severity: 'error', code: 'too_large', message: 'Конфиг больше 512 КиБ.' });
+ }
+ if (!text.trim()) issues.push({ line: 1, column: 1, end_column: 1, severity: 'error', code: 'empty', message: 'Конфиг пуст.' });
+
+ lines.forEach((lineText, i) => {
+ const lineNo = i + 1;
+ const tokens = tokenizeLine(lineText);
+ if (!tokens.length) return;
+ const lower = tokens.map((t) => t.text.toLowerCase());
+ const first = lower[0];
+ const indented = /^\s/.test(lineText);
+ const opens = lower.filter((w) => w === '{').length;
+ const closes = lower.filter((w) => w === '}').length;
+ if (opens !== closes) issue(issues, lineNo, undefined, lineText, 'error', 'unbalanced_braces', 'Непарные фигурные скобки { } в условии.');
+ for (const t of tokens) {
+ if (/^["']/.test(t.text) && (t.text.length < 2 || t.text[t.text.length - 1] !== t.text[0])) issue(issues, lineNo, t, lineText, 'error', 'unterminated_quote', 'Незакрытая кавычка.');
+ }
+
+ if (CONDITIONALS.has(first)) {
+ if (first === '.if') ifStack.push(lineNo);
+ else if (first === '.elif' || first === '.else') { if (!ifStack.length) issue(issues, lineNo, tokens[0], lineText, 'error', 'conditional_unbalanced', `${first} без открывающего .if.`); }
+ else if (first === '.endif') { if (!ifStack.pop()) issue(issues, lineNo, tokens[0], lineText, 'error', 'conditional_unbalanced', '.endif без открывающего .if.'); }
+ if ((first === '.if' || first === '.elif') && tokens.length < 2) issue(issues, lineNo, tokens[0], lineText, 'error', 'conditional_missing', `После ${first} нужно условие.`);
+ return;
+ }
+
+ if (LINT_SECTIONS.has(tokens[0].text)) {
+ finishSection();
+ const name = tokens[1]?.text ?? '';
+ if (indented) issue(issues, lineNo, tokens[0], lineText, 'warning', 'section_indent', `Секция ${first} должна начинаться с первой колонки.`);
+ if (NAMED_SECTIONS.has(first) && !name) issue(issues, lineNo, tokens[0], lineText, 'error', 'section_name_missing', `У секции ${first} должно быть имя.`);
+ if (first === 'global' && tokens.length > 1) issue(issues, lineNo, tokens[1], lineText, 'error', 'unexpected_argument', 'У секции global нет имени.');
+ if (name && PROXY_SECTIONS.has(first) && first !== 'defaults') {
+ const key = `${first === 'frontend' ? 'fe' : 'be'}:${name}`;
+ const listenKey = `${first === 'frontend' ? 'be' : 'fe'}:${name}`;
+ if (proxyNames.has(key) || (first === 'listen' && proxyNames.has(listenKey)) || proxyNames.has(`listen:${name}`)) {
+ issue(issues, lineNo, tokens[1], lineText, 'error', 'duplicate_proxy', `Имя «${name}» уже используется в строке ${proxyNames.get(key) ?? proxyNames.get(listenKey) ?? proxyNames.get(`listen:${name}`)}.`);
+ }
+ if (first === 'listen') { proxyNames.set(`listen:${name}`, lineNo); proxyNames.set(`fe:${name}`, lineNo); proxyNames.set(`be:${name}`, lineNo); }
+ else proxyNames.set(key, lineNo);
+ }
+ if (first === 'defaults') namedDefaults = Boolean(name) && !(name === 'from' && tokens.length === 3);
+ current = { type: first, name, line: lineNo, binds: 0 };
+ sections.push({ type: first, name, line: lineNo });
+ return;
+ }
+
+ if (!current) {
+ issue(issues, lineNo, tokens[0], lineText, 'error', 'outside_section', `«${tokens[0].text}» вне секции: сначала объявите global, defaults, frontend или backend.`);
+ return;
+ }
+ const section = current.type;
+ let words = tokens.map(ruleWord);
+ const emit: RuleEmit = (w, code, message, severity = 'error') => issue(issues, lineNo, { text: w.text, start: w.start, end: w.end }, lineText, severity, code, message);
+ // «no»/«default» modifiers shift the arguments like cfgparse.c does.
+ let modifier = '';
+ if ((words[0].text === 'no' || words[0].text === 'default') && words.length > 1) { modifier = words[0].text; words = words.slice(1); }
+ const kw = words[0];
+ const known = knownKeyword(section, kw.text);
+ if (!indented && !known && !modifier) {
+ const [base, n] = trailingPunct(kw.text);
+ if (n > 0 && knownKeyword(section, base)) emit(punctWord(kw, n), 'stray_punctuation', `Лишний символ «${kw.text.slice(base.length)}» после «${base}»`);
+ else issue(issues, lineNo, tokens[0], lineText, 'error', 'unknown_section', `Неизвестная секция «${tokens[0].text}».`);
+ return;
+ }
+ if (!indented) issue(issues, lineNo, tokens[0], lineText, 'warning', 'indentation', 'Директива внутри секции обычно пишется с отступом.');
+ if (!known) { unknownDirective(section, kw, emit); return; }
+ if (modifier && !negatable(section, kw.text)) { emit(kw, 'negation_not_supported', `Префикс «${modifier}» не поддерживается для «${kw.text}»`); return; }
+ if (isProxySection(section) && !proxyKeywordAllowed(section, namedDefaults, words, emit)) return;
+ const before = issues.length;
+ const proxy = isProxySection(section);
+ switch (kw.text) {
+ case 'option': if (proxy) checkOption(section, words, modifier, emit); break;
+ case 'mode': if (proxy) checkMode(section, words, emit); break;
+ case 'balance': if (proxy) checkBalance(words, emit); break;
+ case 'hash-type': if (proxy) checkHashType(words, emit); break;
+ case 'log': if ((proxy || section === 'global') && !modifier) checkLog(section, words, emit); break;
+ case 'stats': if (section === 'global' && words.length > 2 && words[1].text === 'socket') checkBindKeywords(words, 3, emit); break;
+ case 'default-server': checkServerKeywords(words, 1, emit); break;
+ case 'bind': if (words.length > 1) checkBindKeywords(words, 2, emit); break;
+ case 'server': if (['backend', 'listen', 'peers', 'ring'].includes(section) && words.length > 2) checkServerKeywords(words, 3, emit); break;
+ case 'server-template': if (['backend', 'listen'].includes(section) && words.length > 3) checkServerKeywords(words, 4, emit); break;
+ }
+ if (kw.text === 'timeout' && !checkTimeoutKind(section, words, emit)) return;
+ const tail = strayTail(words);
+ if (tail && !issues.slice(before).some((i) => i.line === lineNo && i.column <= tail.word.start + 1 && tail.word.start + 1 < i.end_column)) {
+ emit(tail.word, 'stray_punctuation', `Лишний символ «${tail.punct}» в конце строки`);
+ }
+
+ if (first === 'timeout' || first === 'hard-stop-after' || (first === 'tcp-request' && lower[1] === 'inspect-delay') || (first === 'hold' && current.type === 'resolvers') || (first === 'stats' && lower[1] === 'timeout')) {
+ const valueIndex = first === 'hard-stop-after' ? 1 : 2;
+ const value = tokens[valueIndex];
+ if (!value) issue(issues, lineNo, tokens[valueIndex - 1] ?? tokens[0], lineText, 'error', 'timeout_missing', 'Не указано значение времени, например 5s или 15m.');
+ else if (!DURATION_RE.test(value.text)) issue(issues, lineNo, value, lineText, 'error', 'timeout_format', `«${value.text}» — неверное время. Формат: число и единица us, ms, s, m, h или d (например 5s).`);
+ }
+
+ if (first === 'bind') {
+ current.binds++;
+ const addr = tokens[1];
+ if (!addr) issue(issues, lineNo, tokens[0], lineText, 'error', 'bind_address_missing', 'У bind не указан адрес и порт.');
+ else {
+ for (const piece of addr.text.split(',')) {
+ if (/^(unix@|abns@|fd@|sockpair@|\/|quic)/i.test(piece)) continue;
+ const parsed = parseAddress(piece);
+ if (!parsed || !parsed.port) { issue(issues, lineNo, addr, lineText, 'error', 'bind_port_missing', `В «${piece}» не указан порт (пример :443).`); continue; }
+ if (!validPort(parsed.port)) { issue(issues, lineNo, addr, lineText, 'error', 'port_range', `Порт «${parsed.port}» вне диапазона 1–65535.`); continue; }
+ if (current.type === 'frontend' || current.type === 'listen') {
+ const [low, high = low] = parsed.port.split('-').map(Number);
+ for (let p = low; p <= high && p - low < 1024; p++) ports.add(p);
+ }
+ }
+ }
+ }
+
+ if (first === 'server' || first === 'server-template') {
+ const addr = tokens[first === 'server' ? 2 : 3];
+ if (!addr && !(current.type === 'peers')) issue(issues, lineNo, tokens[1] ?? tokens[0], lineText, 'error', 'server_address_missing', 'У server не указан адрес.');
+ else if (addr && !/^(unix@|abns@|\/|\$)/i.test(addr.text)) {
+ const parsed = parseAddress(addr.text) ?? (/:([^:\]]+)$/.exec(addr.text) ? { host: '', port: /:([^:\]]+)$/.exec(addr.text)![1] } : null);
+ if (parsed?.port && !/^[+-]/.test(parsed.port) && !validPort(parsed.port)) issue(issues, lineNo, addr, lineText, 'error', 'port_range', `Порт «${parsed.port}» вне диапазона 1–65535.`);
+ }
+ }
+
+ if ((first === 'use_backend' || first === 'default_backend') && tokens[1] && !tokens[1].text.includes('%[')) {
+ backendRefs.push({ name: tokens[1].text, line: lineNo, token: tokens[1], text: lineText });
+ }
+ if ((first === 'use_backend' || first === 'default_backend') && !tokens[1]) issue(issues, lineNo, tokens[0], lineText, 'error', 'backend_missing', `У ${first} не указан backend.`);
+ if (first === 'use_backend' && tokens[1] && tokens.length > 2 && !['if', 'unless'].includes(lower[2])) issue(issues, lineNo, tokens[2], lineText, 'error', 'condition_keyword', 'После имени backend ожидается if или unless.');
+ });
+ finishSection();
+ for (const line of ifStack) issues.push({ line, column: 1, end_column: 1 + (lines[line - 1]?.length ?? 1), severity: 'error', code: 'conditional_unbalanced', message: '.if без закрывающего .endif.' });
+ for (const ref of backendRefs) {
+ if (!proxyNames.has(`be:${ref.name}`)) issue(issues, ref.line, ref.token, ref.text, 'error', 'unknown_backend', `Backend «${ref.name}» не объявлен.`);
+ }
+ issues.sort((a, b) => a.line - b.line || a.column - b.column);
+ return { valid: !issues.some((i) => i.severity === 'error'), issues, listener_tcp_ports: [...ports].sort((a, b) => a - b), sections };
+}
+
+/** Section kind for completion at a line (0-based). */
+export function sectionKindAt(lines: string[], lineIndex: number): string | null {
+ return sectionAt(lines, lineIndex)?.type ?? null;
+}
diff --git a/frontend/src/features/haproxy-editor/monacoSetup.ts b/frontend/src/features/haproxy-editor/monacoSetup.ts
new file mode 100644
index 0000000..ed179e0
--- /dev/null
+++ b/frontend/src/features/haproxy-editor/monacoSetup.ts
@@ -0,0 +1,258 @@
+// Local Monaco bundle for the haproxy.cfg editor. Loaded only by the lazy
+// HAProxy config page: no CDN, one editor worker, no bundled languages.
+// Curated subset of monaco-editor/esm/vs/editor/editor.all.js: only the
+// contributions this editor uses, to keep the lazy chunk small.
+import 'monaco-editor/esm/vs/editor/browser/coreCommands.js';
+import 'monaco-editor/esm/vs/editor/browser/widget/codeEditor/codeEditorWidget.js';
+import 'monaco-editor/esm/vs/editor/browser/widget/diffEditor/diffEditor.contribution.js';
+import 'monaco-editor/esm/vs/editor/contrib/bracketMatching/browser/bracketMatching.js';
+import 'monaco-editor/esm/vs/editor/contrib/clipboard/browser/clipboard.js';
+import 'monaco-editor/esm/vs/editor/contrib/comment/browser/comment.js';
+import 'monaco-editor/esm/vs/editor/contrib/contextmenu/browser/contextmenu.js';
+import 'monaco-editor/esm/vs/editor/contrib/cursorUndo/browser/cursorUndo.js';
+import 'monaco-editor/esm/vs/editor/contrib/find/browser/findController.js';
+import 'monaco-editor/esm/vs/editor/contrib/folding/browser/folding.js';
+import 'monaco-editor/esm/vs/editor/contrib/format/browser/formatActions.js';
+import 'monaco-editor/esm/vs/editor/contrib/gotoError/browser/gotoError.js';
+import 'monaco-editor/esm/vs/editor/contrib/hover/browser/hoverContribution.js';
+import 'monaco-editor/esm/vs/editor/contrib/indentation/browser/indentation.js';
+import 'monaco-editor/esm/vs/editor/contrib/linesOperations/browser/linesOperations.js';
+import 'monaco-editor/esm/vs/editor/contrib/multicursor/browser/multicursor.js';
+import 'monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestController.js';
+import 'monaco-editor/esm/vs/editor/contrib/tokenization/browser/tokenization.js';
+import 'monaco-editor/esm/vs/editor/contrib/wordHighlighter/browser/wordHighlighter.js';
+import 'monaco-editor/esm/vs/editor/contrib/wordOperations/browser/wordOperations.js';
+import 'monaco-editor/esm/vs/editor/contrib/readOnlyMessage/browser/contribution.js';
+import 'monaco-editor/esm/vs/editor/common/standaloneStrings.js';
+import 'monaco-editor/esm/vs/base/browser/ui/codicons/codiconStyles.js';
+import 'monaco-editor/esm/vs/editor/standalone/browser/quickAccess/standaloneGotoLineQuickAccess.js';
+import 'monaco-editor/esm/vs/editor/standalone/browser/quickAccess/standaloneCommandsQuickAccess.js';
+import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js';
+import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker.js?worker';
+import { loader } from '@monaco-editor/react';
+import { argDocs, directiveDocs, keywordDocs, SECTION_KEYWORDS, sectionDocs, type HAProxyDoc } from './haproxyDocs.ts';
+import { formatConfig, hoverMarkdown, resolveHover, sectionKindAt, tokenizeLine } from './haproxyModel.ts';
+
+export const HAPROXY_LANGUAGE = 'haproxy';
+export const HAPROXY_THEME = 'nodeflow-dark';
+
+declare global {
+ interface Window { MonacoEnvironment?: monaco.Environment }
+}
+
+self.MonacoEnvironment = { getWorker: () => new EditorWorker() };
+loader.config({ monaco });
+
+const optionWords = [...new Set(Object.values(argDocs).flatMap((group) => Object.keys(group)))].filter((word) => /^[a-z][\w.-]*$/.test(word));
+
+function docMarkdown(title: string, doc: HAProxyDoc): string {
+ return [`**\`${title}\`**`, doc.summary, doc.syntax ? '```haproxy\n' + doc.syntax + '\n```' : '', doc.warning ?? ''].filter(Boolean).join('\n\n');
+}
+
+function registerLanguage() {
+ monaco.languages.register({ id: HAPROXY_LANGUAGE, extensions: ['.cfg'], aliases: ['HAProxy', 'haproxy'] });
+ monaco.languages.setLanguageConfiguration(HAPROXY_LANGUAGE, {
+ comments: { lineComment: '#' },
+ brackets: [['{', '}'], ['(', ')']],
+ autoClosingPairs: [{ open: '{', close: '}' }, { open: '(', close: ')' }, { open: '"', close: '"' }],
+ wordPattern: /[^\s,()#]+/,
+ indentationRules: { increaseIndentPattern: new RegExp(`^(${SECTION_KEYWORDS.join('|')})\\b.*$`), decreaseIndentPattern: /^\s*\.endif\b/ },
+ });
+ const sectionRe = SECTION_KEYWORDS.map((s) => s.replace('-', '\\-')).join('|');
+ monaco.languages.setMonarchTokensProvider(HAPROXY_LANGUAGE, {
+ ignoreCase: true,
+ optionWords,
+ tokenizer: {
+ root: [
+ [/^\s*#.*$/, 'comment'],
+ [new RegExp(`^(${sectionRe})(\\s+)([^\\s#]+)`), ['keyword.section', 'white', 'type.section']],
+ [new RegExp(`^(${sectionRe})(?=\\s|$)`), 'keyword.section'],
+ [/^(\s*)(\.(?:if|elif|else|endif|notice|warning|alert|diag))\b/, ['white', 'keyword.conditional']],
+ [/^(\s+)(timeout|option|no\s+option|tcp-request|tcp-response|http-request|http-response|stats|stick|tcp-check|http-check|filter|hold|rate-limit)(\s+)([a-z][\w.-]*)/, ['white', 'keyword.directive', 'white', 'keyword.directive']],
+ [/^(\s+)([a-z_][\w.-]*)/, ['white', 'keyword.directive']],
+ [/#.*$/, 'comment'],
+ [/"([^"\\]|\\.)*"?/, 'string'],
+ [/'[^']*'?/, 'string'],
+ [/\b(if|unless|or)\b/, 'keyword.condition'],
+ [/\|\||!/, 'operator'],
+ [/[{}]/, 'delimiter.bracket'],
+ [/[(),]/, 'delimiter'],
+ [/nf_\w+/, 'variable.generated'],
+ [/\[[0-9a-f:.]+\](:\d+(-\d+)?)?/, 'number.address'],
+ [/(ipv4@|ipv6@)?\d{1,3}(\.\d{1,3}){3}(\/\d+)?(:\d+(-\d+)?)?/, 'number.address'],
+ [/(ipv6@)?[0-9a-f]*::?[0-9a-f:]*:\d+\b/, 'number.address'],
+ [/:\d+(-\d+)?\b/, 'number.address'],
+ [/(unix@|abns@)?\/[^\s,#]*/, 'string.path'],
+ [/(req|res|ssl_fc|ssl_c|ssl_s|sc\d|sc_|src|dst|fc|bc)[._][\w.]+/, 'predefined'],
+ [/\b(src|dst|dst_port|src_port|always_true|always_false)\b/, 'predefined'],
+ [/-[a-z]\b/, 'attribute.flag'],
+ [/\d+(us|ms|s|m|h|d|k|g)?\b/, 'number'],
+ [/[a-z][\w.-]*/, { cases: { '@optionWords': 'attribute', '@default': 'identifier' } }],
+ [/\s+/, 'white'],
+ ],
+ },
+ } as monaco.languages.IMonarchLanguage);
+
+ monaco.languages.registerHoverProvider(HAPROXY_LANGUAGE, {
+ provideHover(model, position) {
+ const lines = model.getLinesContent();
+ const hover = resolveHover(lines, position.lineNumber - 1, position.column - 1);
+ if (!hover) return null;
+ return {
+ range: new monaco.Range(position.lineNumber, hover.start + 1, position.lineNumber, hover.end + 1),
+ contents: [{ value: hoverMarkdown(hover) }],
+ };
+ },
+ });
+
+ monaco.languages.registerCompletionItemProvider(HAPROXY_LANGUAGE, {
+ triggerCharacters: [' '],
+ provideCompletionItems(model, position) {
+ const line = model.getLineContent(position.lineNumber);
+ const before = line.slice(0, position.column - 1);
+ const word = model.getWordUntilPosition(position);
+ const range = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn);
+ const kinds = monaco.languages.CompletionItemKind;
+ const item = (label: string, doc: HAProxyDoc, kind: monaco.languages.CompletionItemKind) => ({
+ label, kind, insertText: label, range, detail: doc.syntax, documentation: { value: docMarkdown(label, doc) },
+ });
+ if (/^\S*$/.test(before)) return { suggestions: Object.entries(sectionDocs).map(([key, doc]) => item(key, doc, kinds.Module)) };
+ const tokens = tokenizeLine(before);
+ const atFirstWord = /^\s+\S*$/.test(before);
+ if (atFirstWord) {
+ const section = sectionKindAt(model.getLinesContent(), position.lineNumber - 1);
+ return {
+ suggestions: Object.entries(directiveDocs)
+ .filter(([, doc]) => !section || !doc.sections || doc.sections.includes(section as never))
+ .map(([key, doc]) => item(key, doc, kinds.Keyword)),
+ };
+ }
+ const owner = tokens[0]?.text.toLowerCase() ?? '';
+ const group = argDocs[owner] ?? (owner === 'tcp-request' ? argDocs['tcp-request'] : undefined);
+ const suggestions = group ? Object.entries(group).map(([key, doc]) => item(key, doc, kinds.Property)) : [];
+ suggestions.push(...['if', 'unless', 'src', 'dst_port', 'req.ssl_sni'].map((key) => item(key, keywordDocs[key], kinds.Function)));
+ return { suggestions };
+ },
+ });
+
+ monaco.languages.registerDocumentFormattingEditProvider(HAPROXY_LANGUAGE, {
+ provideDocumentFormattingEdits(model) {
+ return [{ range: model.getFullModelRange(), text: formatConfig(model.getValue()) }];
+ },
+ });
+}
+
+const themeRules: monaco.editor.ITokenThemeRule[] = [
+ { token: 'keyword.section', foreground: '5fd38d', fontStyle: 'bold' },
+ { token: 'type.section', foreground: 'e5c07b', fontStyle: 'bold' },
+ { token: 'keyword.conditional', foreground: 'c678dd', fontStyle: 'bold' },
+ { token: 'keyword.directive', foreground: '61afef' },
+ { token: 'keyword.condition', foreground: 'c678dd' },
+ { token: 'operator', foreground: 'c678dd' },
+ { token: 'attribute', foreground: '56b6c2' },
+ { token: 'attribute.flag', foreground: 'd19a66' },
+ { token: 'predefined', foreground: 'e06c75' },
+ { token: 'number', foreground: 'd19a66' },
+ { token: 'number.address', foreground: 'b5cea8' },
+ { token: 'string', foreground: '98c379' },
+ { token: 'string.path', foreground: '98c379' },
+ { token: 'variable.generated', foreground: 'e5c07b' },
+ { token: 'comment', foreground: '6b7f78', fontStyle: 'italic' },
+ { token: 'delimiter.bracket', foreground: 'abb2bf' },
+ { token: 'identifier', foreground: 'd7dfdb' },
+];
+
+const themeColors: monaco.editor.IColors = {
+ 'editor.background': '#07110f',
+ 'editor.foreground': '#d7dfdb',
+ 'editorLineNumber.foreground': '#4b5f58',
+ 'editorLineNumber.activeForeground': '#9fb3ab',
+ 'editor.lineHighlightBackground': '#0f1d18',
+ 'editor.selectionBackground': '#1f4a33',
+ 'editorGutter.background': '#07110f',
+ 'minimap.background': '#081411',
+ 'editorHoverWidget.background': '#0d1a16',
+ 'editorHoverWidget.border': '#2c4a3d',
+ 'editorHoverWidget.foreground': '#d7dfdb',
+ 'editorSuggestWidget.background': '#0d1a16',
+ 'editorSuggestWidget.border': '#2c4a3d',
+ 'editorSuggestWidget.selectedBackground': '#13271d',
+ 'editorWidget.background': '#0d1a16',
+ 'editorWidget.border': '#2c4a3d',
+ 'textCodeBlock.background': '#07110f',
+ 'textLink.foreground': '#5fd38d',
+ 'scrollbarSlider.background': '#5fd38d22',
+ 'scrollbarSlider.hoverBackground': '#5fd38d44',
+ 'editorError.foreground': '#ff6b6b',
+ 'editorWarning.foreground': '#e5c07b',
+ 'diffEditor.insertedTextBackground': '#2ea04333',
+ 'diffEditor.removedTextBackground': '#f8514933',
+};
+
+/** Resolves a CSS color (any syntax, incl. var()/oklch) to #rrggbb[aa] via a canvas pixel. */
+function cssColor(expression: string, fallback: string): string {
+ try {
+ const probe = document.createElement('span');
+ probe.style.color = expression;
+ document.body.appendChild(probe);
+ const computed = getComputedStyle(probe).color;
+ probe.remove();
+ const canvas = document.createElement('canvas');
+ canvas.width = canvas.height = 1;
+ const context = canvas.getContext('2d', { willReadFrequently: true });
+ if (!context || !computed) return fallback;
+ context.fillStyle = computed;
+ context.fillRect(0, 0, 1, 1);
+ const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data;
+ const hex = (value: number) => value.toString(16).padStart(2, '0');
+ return `#${hex(r)}${hex(g)}${hex(b)}${a < 255 ? hex(a) : ''}`;
+ } catch {
+ return fallback;
+ }
+}
+
+/** Re-tints the editor chrome with the active NodeFlow appearance theme. */
+function applyAppearance() {
+ const field = cssColor('var(--nf-field)', '#07110f');
+ const surface = cssColor('var(--nf-surface-soft)', '#0d1a16');
+ const raised = cssColor('var(--nf-surface-raised)', '#0f1d18');
+ const border = cssColor('var(--nf-border-strong)', '#2c4a3d');
+ const accent = cssColor('var(--nf-accent)', '#5fd38d');
+ const accentSoft = cssColor('var(--nf-accent-soft)', '#1f4a33');
+ const text = cssColor('var(--nf-text)', '#d7dfdb');
+ const muted = cssColor('var(--nf-text-tertiary)', '#4b5f58');
+ monaco.editor.defineTheme(HAPROXY_THEME, {
+ base: 'vs-dark',
+ inherit: true,
+ rules: [...themeRules, { token: 'keyword.section', foreground: accent.slice(1, 7), fontStyle: 'bold' }],
+ colors: {
+ ...themeColors,
+ 'editor.background': field,
+ 'editorGutter.background': field,
+ 'editor.foreground': text,
+ 'editorLineNumber.foreground': muted,
+ 'editor.lineHighlightBackground': raised,
+ 'editor.selectionBackground': accentSoft.length === 9 ? accentSoft.slice(0, 7) + '66' : accentSoft,
+ 'minimap.background': field,
+ 'editorHoverWidget.background': surface,
+ 'editorHoverWidget.border': border,
+ 'editorHoverWidget.foreground': text,
+ 'editorSuggestWidget.background': surface,
+ 'editorSuggestWidget.border': border,
+ 'editorSuggestWidget.selectedBackground': raised,
+ 'editorWidget.background': surface,
+ 'editorWidget.border': border,
+ 'textCodeBlock.background': field,
+ 'textLink.foreground': accent,
+ },
+ });
+}
+
+let registered = false;
+export function setupHAProxyMonaco(): typeof monaco {
+ if (!registered) { registerLanguage(); registered = true; }
+ applyAppearance();
+ return monaco;
+}
+
+export { monaco };
diff --git a/frontend/src/features/node-detail/nodeSettings.ts b/frontend/src/features/node-detail/nodeSettings.ts
index e96d535..60f8040 100644
--- a/frontend/src/features/node-detail/nodeSettings.ts
+++ b/frontend/src/features/node-detail/nodeSettings.ts
@@ -15,13 +15,75 @@ export function nodeHAProxyLogs(node: Pick & { haproxy_l
/**
* PUT /api/v1/nodes/{id} body. metadata is sent back unchanged apart from the
- * logging key, so other node metadata (ports, traffic limits) is preserved.
+ * logging key (and haproxy_settings when given), so other node metadata
+ * (ports, traffic limits) is preserved.
*/
-export function nodeSettingsPayload(node: Pick, name: string, address: string, haproxyLogs: boolean) {
+export function nodeSettingsPayload(node: Pick, name: string, address: string, haproxyLogs: boolean, haproxySettings?: HAProxyTuning) {
return {
name,
address,
- metadata: { ...(node.metadata ?? {}), haproxy_logs: haproxyLogs },
+ metadata: { ...(node.metadata ?? {}), haproxy_logs: haproxyLogs, ...(haproxySettings ? { haproxy_settings: haproxySettings } : {}) },
haproxy_logs: haproxyLogs,
};
}
+
+/** metadata.haproxy_settings; every field optional (= renderer default). */
+export interface HAProxyTuning {
+ max_connections?: number;
+ threads?: number;
+ timeout_connect?: string;
+ timeout_client?: string;
+ timeout_server?: string;
+}
+
+/** Form state of the «HAProxy» block: raw strings, empty = default. */
+export interface HAProxyTuningForm {
+ maxconn: string;
+ nbthread: string;
+ timeoutConnect: string;
+ timeoutClient: string;
+ timeoutServer: string;
+}
+
+const TIMEOUT_PATTERN = /^[1-9][0-9]{0,7}(ms|s|m|h)$/;
+
+export function haproxyTuningForm(node: Pick): HAProxyTuningForm {
+ const raw = node.metadata?.haproxy_settings;
+ const value = (raw && typeof raw === 'object' ? raw : {}) as Record;
+ const text = (key: string) => (typeof value[key] === 'string' ? value[key] as string : typeof value[key] === 'number' && value[key] ? String(value[key]) : '');
+ return {
+ maxconn: text('max_connections'),
+ nbthread: text('threads'),
+ timeoutConnect: text('timeout_connect'),
+ timeoutClient: text('timeout_client'),
+ timeoutServer: text('timeout_server'),
+ };
+}
+
+/** Field errors (Russian) keyed like the form; empty object = valid. */
+export function validateHAProxyTuning(form: HAProxyTuningForm): Partial> {
+ const errors: Partial> = {};
+ const integer = (key: 'maxconn' | 'nbthread', max: number) => {
+ const value = form[key].trim();
+ if (!value) return;
+ if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > max) errors[key] = `Целое число 1–${max.toLocaleString('ru-RU')}`;
+ };
+ integer('maxconn', 10_000_000);
+ integer('nbthread', 256);
+ for (const key of ['timeoutConnect', 'timeoutClient', 'timeoutServer'] as const) {
+ const value = form[key].trim();
+ if (value && !TIMEOUT_PATTERN.test(value)) errors[key] = 'Формат: 5s, 15m';
+ }
+ return errors;
+}
+
+/** metadata.haproxy_settings value: empty fields omitted, {} resets to defaults. */
+export function haproxyTuningPayload(form: HAProxyTuningForm): HAProxyTuning {
+ const out: HAProxyTuning = {};
+ if (form.maxconn.trim()) out.max_connections = Number(form.maxconn.trim());
+ if (form.nbthread.trim()) out.threads = Number(form.nbthread.trim());
+ if (form.timeoutConnect.trim()) out.timeout_connect = form.timeoutConnect.trim();
+ if (form.timeoutClient.trim()) out.timeout_client = form.timeoutClient.trim();
+ if (form.timeoutServer.trim()) out.timeout_server = form.timeoutServer.trim();
+ return out;
+}
diff --git a/frontend/src/features/nodes/AddNodeDialog.tsx b/frontend/src/features/nodes/AddNodeDialog.tsx
index 0d7a7a9..c43000e 100644
--- a/frontend/src/features/nodes/AddNodeDialog.tsx
+++ b/frontend/src/features/nodes/AddNodeDialog.tsx
@@ -75,6 +75,7 @@ export function AddNodeDialog({ opened, onClose, onInstalled, reinstallTarget, d
const [hostKey, setHostKey] = useState(null);
const [hostKeyAccepted, setHostKeyAccepted] = useState(false);
const [allowFirewallApply, setAllowFirewallApply] = useState(true);
+ const [haproxyLogs, setHAProxyLogs] = useState(true);
const [detectedOS, setDetectedOS] = useState('');
const [detectedArch, setDetectedArch] = useState('');
const [releases, setReleases] = useState([]);
@@ -100,6 +101,7 @@ export function AddNodeDialog({ opened, onClose, onInstalled, reinstallTarget, d
setPort(reinstallTarget?.sshPort ?? 22);
setAgentPort(reinstallTarget?.agentPort ?? 4200);
setAllowFirewallApply(reinstallTarget?.allowFirewallApply ?? true);
+ setHAProxyLogs(true);
setDetectedOS(reinstallTarget?.os?.trim().toLowerCase() || '');
setDetectedArch(reinstallTarget?.arch?.trim().toLowerCase() || '');
}, [opened, reinstallTarget?.id, reinstallTarget?.allowFirewallApply, reinstallTarget?.agentPort, reinstallTarget?.sshPort, reinstallTarget?.os, reinstallTarget?.arch]);
@@ -149,7 +151,7 @@ export function AddNodeDialog({ opened, onClose, onInstalled, reinstallTarget, d
const dirty = Boolean(name.trim() !== (reinstallTarget?.name ?? '') || address.trim() !== (reinstallTarget?.address ?? '')
|| password || privateKey || privateKeyPassphrase || sudoPassword
|| username !== 'root' || Number(port) !== (reinstallTarget?.sshPort ?? 22) || Number(agentPort) !== (reinstallTarget?.agentPort ?? 4200) || authMode !== 'password'
- || sudoMode !== 'auto' || allowFirewallApply !== (reinstallTarget?.allowFirewallApply ?? true)
+ || sudoMode !== 'auto' || allowFirewallApply !== (reinstallTarget?.allowFirewallApply ?? true) || !haproxyLogs
|| selectedReleaseID !== automaticReleaseValue || hostKey);
const jobActive = job?.status === 'queued' || job?.status === 'running';
const requestClose = () => {
@@ -220,6 +222,7 @@ export function AddNodeDialog({ opened, onClose, onInstalled, reinstallTarget, d
name: name.trim(), address: address.trim(), ssh_port: Number(port), username: username.trim(), auth_mode: authMode,
sudo_mode: sudoMode, agent_port: Number(agentPort), host_key_sha256: hostKey.fingerprint,
host_key_algorithm: hostKey.algorithm, allow_firewall_apply: allowFirewallApply,
+ ...(!reinstall && !haproxyLogs ? { haproxy_logs: false } : {}),
...(!automaticRelease ? { release_id: selectedReleaseID } : {}),
...(authMode === 'password' ? { password } : { private_key: privateKey, private_key_passphrase: privateKeyPassphrase }),
...(sudoPassword ? { sudo_password: sudoPassword } : {}),
@@ -306,7 +309,10 @@ export function AddNodeDialog({ opened, onClose, onInstalled, reinstallTarget, d
{sudoMode === 'password' &&