diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 7c4724c..9f77eec 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -11,7 +11,7 @@ body: attributes: label: Версии description: Panel, Node Agent, HAProxy и ОС ноды. - placeholder: "Panel 2.1.0, Agent 2.1.0, HAProxy 3.4, Ubuntu 24.04" + placeholder: "Panel 2.2.0, Agent 2.2.0, HAProxy 3.4, Ubuntu 24.04" validations: required: true - type: textarea diff --git a/CHANGELOG.md b/CHANGELOG.md index de38902..e524d01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 2.2.0 — Закрепление в пулах с резервными серверами, копирование и шаблоны маршрутов + +Panel и Node Agent — 2.2.0 (код Agent не менялся), HAProxy renderer v21, миграции 000056 (`route_templates`) и 000057 (прежнее распределение старых failover-маршрутов). Заметки к релизу: [docs/releases/2.2.0.md](docs/releases/2.2.0.md). Спасибо @fesevu (#19, #20). + +- **Закрепление клиентов и `leastconn` в DNS-пулах с резервным сервером** (#19). Ограничения: `leastping`/`leastconn` с tolerance > 0 в режиме с резервным сервером отклоняются; клиент, закреплённый за резервным сервером, остаётся на нём до истечения записи. Новый failover по умолчанию без закрепления; `leastping` с `source` отклоняется. +- **Копирование маршрутов между нодами** — копия создаётся выключенным черновиком (#20). +- **Библиотека шаблонов маршрутов** с `schema_version` (#20, миграция 000056). +- **Исправлено:** миграция 000057 сохраняет рендер старых failover-маршрутов — маршруты с устаревшим `source_table` один раз публикуются заново с идентичным конфигом; повторное сохранение в UI сохраняет распределение. +- Аудит `route.copy` и `route_template.*`; ошибки и имена копий/шаблонов; диалоги в стиле панели. + ## 2.1.0 — Настройки HAProxy ноды и редактор конфига Panel и Node Agent — 2.1.0, HAProxy renderer v21, миграция 000055 (`node_config_state.last_error_detail`). Заметки к релизу: [docs/releases/2.1.0.md](docs/releases/2.1.0.md). Идея и первая версия — @fesevu (#15). diff --git a/README.md b/README.md index 2a5974c..a038b65 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Установка · Обновление · Скриншоты · - Что нового в 2.1.0 · + Что нового в 2.2.0 · Issues · Поддержка

@@ -60,10 +60,10 @@ curl -fsSL https://raw.githubusercontent.com/NodeFlow-dev/nodeflow/main/install. 2. предложит режим доступа: Caddy cookie-gate (ссылка активации ставит защищённую cookie) или без него — токен администратора Panel нужен в любом случае; 3. установит Docker и **Caddy** — Caddy сам получит сертификат Let's Encrypt и будет проксировать HTTPS на Panel, которая слушает только `127.0.0.1:8080`; 4. скачает install kit последнего релиза, проверит его по SHA-256, который GitHub публикует для каждого asset релиза, возьмёт из него `compose.release.yaml`, сгенерирует `.env`, CA, сертификат mTLS и ключ подписи обновлений в `/opt/nodeflow`; -5. выполнит `docker compose pull && docker compose up -d` с образом `ghcr.io/nodeflow-dev/nodeflow-panel:2.1.0` — на сервере ничего не собирается, миграции БД применяет сам образ; +5. выполнит `docker compose pull && docker compose up -d` с образом `ghcr.io/nodeflow-dev/nodeflow-panel:2.2.0` — на сервере ничего не собирается, миграции БД применяет сам образ; 6. опубликует подписанные релизы Node Agent (amd64 и arm64) и сохранит реквизиты входа в `~/nodeflow-credentials.txt` (права `0600`). -Файрвол установщик не меняет. Для автоматизации без вопросов передайте переменные `NODEFLOW_DOMAIN`, `NODEFLOW_AUTH_MODE=cookie|none` и при необходимости `NODEFLOW_VERSION=2.1.0` (по умолчанию — последний релиз): +Файрвол установщик не меняет. Для автоматизации без вопросов передайте переменные `NODEFLOW_DOMAIN`, `NODEFLOW_AUTH_MODE=cookie|none` и при необходимости `NODEFLOW_VERSION=2.2.0` (по умолчанию — последний релиз): ```bash curl -fsSL https://raw.githubusercontent.com/NodeFlow-dev/nodeflow/main/install.sh \ @@ -92,7 +92,7 @@ sudo NODE_AGENT_TOKEN=<токен> bash install-node.sh Команды выполняются от root (`sudo -i`): каталог `/opt/nodeflow` закрыт для остальных пользователей. ```bash -v=2.1.0 +v=2.2.0 kit=NodeFlow-Panel-$v-Agent-$v-install-kit curl -fsSLO "https://github.com/NodeFlow-dev/nodeflow/releases/download/v$v/$kit.tar.gz" # сверьте с digest asset'а на странице релиза или в API: @@ -121,7 +121,7 @@ curl -fsSL https://raw.githubusercontent.com/NodeFlow-dev/nodeflow/main/install. Найдя `/opt/nodeflow/.env`, он работает как апгрейдер: делает `pg_dump` и архив конфигурации в `/var/backups/nodeflow/`, сохраняет `.env`, `tls/`, `pki/` и Caddy-сниппет `/etc/caddy/conf.d/nodeflow-panel.caddy`, переводит установку со сборки из исходников на образ ghcr.io, запускает миграции и проверяет, что Panel отвечает новой версией. При ошибке возвращаются прежние `compose.yaml` и `.env`; дамп БД остаётся (миграции автоматически не откатываются). -Затем в **Настройки → Node Agent** обновите ноды до 2.1.0 — updater заменит Agent атомарно и откатит его при неудачном запуске. Самообновление не меняет systemd-юнит, поэтому на нодах, установленных версиями 1.0.x, один раз добавьте права на настройку pipe-буферов ядра (без этого splice остаётся на 256 КиБ) — или переустановите Agent из меню ноды: +Затем в **Настройки → Node Agent** обновите ноды до 2.2.0 — updater заменит Agent атомарно и откатит его при неудачном запуске. Самообновление не меняет systemd-юнит, поэтому на нодах, установленных версиями 1.0.x, один раз добавьте права на настройку pipe-буферов ядра (без этого splice остаётся на 256 КиБ) — или переустановите Agent из меню ноды: ```bash sudo install -d /etc/systemd/system/nodeflow-node-agent.service.d @@ -175,7 +175,7 @@ go test ./... ## Документация -- Заметки к релизам [2.1.0](docs/releases/2.1.0.md), [2.0.1](docs/releases/2.0.1.md) и [2.0.0](docs/releases/2.0.0.md), [CHANGELOG](CHANGELOG.md) +- Заметки к релизам [2.2.0](docs/releases/2.2.0.md), [2.1.0](docs/releases/2.1.0.md), [2.0.1](docs/releases/2.0.1.md) и [2.0.0](docs/releases/2.0.0.md), [CHANGELOG](CHANGELOG.md) - [API Panel](docs/api-contract.md) - [Настройки HAProxy ноды и редактор конфига](docs/features/haproxy-configuration.md) - [Архитектура](docs/architecture.md) diff --git a/cmd/node-agent/main.go b/cmd/node-agent/main.go index 7e757fc..3d69c1d 100644 --- a/cmd/node-agent/main.go +++ b/cmd/node-agent/main.go @@ -14,7 +14,7 @@ import ( "github.com/nodeflow/nodeflow/internal/agent" ) -var version = "2.1.0" +var version = "2.2.0" func main() { showVersion := flag.Bool("version", false, "print Node Agent version and exit") diff --git a/cmd/panel-api/main.go b/cmd/panel-api/main.go index f56cb8e..18e31e8 100644 --- a/cmd/panel-api/main.go +++ b/cmd/panel-api/main.go @@ -18,7 +18,7 @@ import ( // panelVersion is the published Panel release version. Release builds may // replace it with -ldflags "-X main.panelVersion=...". -var panelVersion = "2.1.0" +var panelVersion = "2.2.0" func main() { if len(os.Args) > 1 { diff --git a/compose.release.yaml b/compose.release.yaml index e98bd19..28dac25 100644 --- a/compose.release.yaml +++ b/compose.release.yaml @@ -30,7 +30,7 @@ services: <<: *hardening migrate: - image: ghcr.io/nodeflow-dev/nodeflow-panel:${NODEFLOW_VERSION:-2.1.0} + image: ghcr.io/nodeflow-dev/nodeflow-panel:${NODEFLOW_VERSION:-2.2.0} restart: "no" command: ["migrate"] depends_on: @@ -79,7 +79,7 @@ services: <<: *hardening panel-api: - image: ghcr.io/nodeflow-dev/nodeflow-panel:${NODEFLOW_VERSION:-2.1.0} + image: ghcr.io/nodeflow-dev/nodeflow-panel:${NODEFLOW_VERSION:-2.2.0} restart: unless-stopped depends_on: migrate: diff --git a/docs/api-contract.md b/docs/api-contract.md index 3c10392..82d2709 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -280,12 +280,14 @@ keeps working. - `pool`: all servers are active; `backup` flags are cleared. Client distribution is selected by `balance_algorithm` and `sticky_mode` (below). - `failover`: position order is priority. The first server is active, every - later server gets `backup`. `balance_algorithm`, `sticky_mode` and their - settings are accepted, validated and stored empty; `sticky_enabled` is - forced false. No balance directives are emitted. + later server gets `backup`. Явные `sticky_mode` (`none`, `source`, + `source_table`) и `balance_algorithm` теперь сохраняются независимо от ролей. + Без явных настроек сохраняется прежнее поведение без закрепления. + Для резерва доступны алгоритмы самого HAProxy; `leastping` и `leastconn` + с ненулевой толерантностью не поддерживаются агентом для такой топологии. At least one server must be active. -### Client distribution (pool mode, migration 000052) +### Client distribution (pool and failover) Two orthogonal settings. `balance_algorithm` picks the server for a **new** client; `sticky_mode` decides whether a returning client keeps its server. @@ -1064,3 +1066,25 @@ groups: annotations: summary: "Node {{ $labels.node }} config not in sync for 10+ min" ``` + +### Шаблоны и копирование маршрутов + +Административные endpoints: + +- `GET /api/v1/route-templates` — общая библиотека. +- `POST /api/v1/route-templates` — создать шаблон: `name`, `description` и + `config` (тело обычного POST маршрута) либо `source_node_id` + `source_route_id`. +- `GET /api/v1/route-templates/{id}` — настройки и версия шаблона. +- `PUT /api/v1/route-templates/{id}` — заменить шаблон; `name`, `description`, + `config` и обязательный `expected_version`. +- `DELETE /api/v1/route-templates/{id}` — тело с `expected_version`. +- `POST /api/v1/route-templates/{id}/apply` — `node_ids` (1–100 уникальных UUID), + `expected_version`, необязательное `name` новых маршрутов. +- `POST /api/v1/nodes/{node}/routes/{route}/copy` — `node_ids` и необязательное + `name` новых маршрутов. + +Применение и копирование возвращают массив `{node_id, route}` либо +`{node_id, error}` для каждой ноды. Каждая копия создаётся самостоятельной +транзакцией и всегда выключена; публикация — отдельная операция. Повтор +запроса создаёт новые копии. Параметры и ограничения валидируются тем же +кодом, что и обычный маршрут. Runtime-состояние в шаблоне отсутствует. diff --git a/docs/brand/nodeflow-hero.svg b/docs/brand/nodeflow-hero.svg index b1a34fa..7476609 100644 --- a/docs/brand/nodeflow-hero.svg +++ b/docs/brand/nodeflow-hero.svg @@ -18,7 +18,7 @@ Control the flow.Keep the control. Ноды, TCP/SNI-маршруты, трафик и безопасные ревизии HAProxy —в одной прозрачной control plane. - v2.1.0HAProxyDocker Compose + v2.2.0HAProxyDocker Compose Paneldesired state Node AgentmTLS heartbeat diff --git a/docs/features/route-templates.md b/docs/features/route-templates.md new file mode 100644 index 0000000..0e247cb --- /dev/null +++ b/docs/features/route-templates.md @@ -0,0 +1,52 @@ +# Копирование маршрутов и шаблоны + +В меню строки маршрута доступны: + +- **Дублировать маршрут** — открыть заполненный редактор нового маршрута на + этой ноде. Имя получает суффикс «копия». Изменения исходного маршрута не + сохраняются в копию автоматически. +- **Копировать на ноды** — выбрать одну или несколько нод и создать на них + выключенные копии. +- **Сохранить как шаблон** — сохранить настройки в общую библиотеку + (название и необязательное описание). + +Кнопка **Шаблоны маршрутов** на странице ноды открывает общую библиотеку. +Выберите шаблон и целевые ноды, чтобы создать маршруты сразу на нескольких +нодах. Для предварительной настройки нажмите **Настроить для этой ноды**. +Шаблон можно отредактировать полным редактором маршрута или удалить. +Сохранить текущие настройки как новый шаблон можно и из редактора маршрута. + +Шаблон — сохранённый снимок настроек. Созданные маршруты независимы: +изменение или удаление шаблона не меняет их. Автоматической синхронизации +общего маршрута между нодами нет. + +Переносятся listener, SNI, серверы и их роли, DNS-пулы, веса, закрепление, +PROXY protocol, лимиты, плавный ввод и ручные backend-директивы. Не переносятся +идентификаторы, история, применённые ревизии, состояние публикации, соединения, +таблицы закрепления и использованный трафик. Новые маршруты всегда выключены. + +Перед включением проверьте привязанный listener IP, пути Unix-сокетов, +доступность бэкендов и поддержку параметров агентом целевой ноды. Конфликты +SNI и listener проверяются штатной процедурой включения. Копирование не +заменяет существующие маршруты и не публикует конфигурацию HAProxy. + +При копировании на несколько нод результат показывается для каждой. +Успешные копии сохраняются даже при ошибке на другой ноде. Для повторной +попытки остаются выбраны только ноды с ошибками. Если ответ потерян из-за +сети, проверьте списки маршрутов перед повтором: запрос копирования не +является идемпотентным. + +Шаблоны хранятся в PostgreSQL (миграция 000056) и доступны только через +административную авторизацию. Изменение, удаление и применение требуют +актуальную версию шаблона. Устаревшая операция получает 409. + +Поле `version` шаблона — только ревизия для оптимистичной блокировки +(растёт при каждом изменении). Формат сохранённых настроек задаёт +`config.schema_version` (сейчас 1; настройки без поля считаются версией 1). +Шаблон с более новой `schema_version`, чем знает панель, не применяется и не +сохраняется: ответ 400 `unsupported_template_schema`. + +Копия без указанного имени на ту же ноду получает суффикс « (копия)»; на +других нодах имя сохраняется. В аудите копирование записывается как +`route.copy` (маршрут-источник и `node_ids`), операции с шаблонами — как +`route_template.create|update|delete|apply`. diff --git a/docs/features/sticky-backup-pools.md b/docs/features/sticky-backup-pools.md new file mode 100644 index 0000000..ee9e6b5 --- /dev/null +++ b/docs/features/sticky-backup-pools.md @@ -0,0 +1,34 @@ +# Закрепление клиентов с резервными серверами + +Роли серверов и распределение клиентов настраиваются независимо. В редакторе +маршрута выберите «Основной + резервные», затем «Запоминать клиента», алгоритм +первого сервера «Меньше соединений» и срок `24h`. + +Для основного DNS-пула добавьте домен с включённым DNS-пулом первой строкой, +а резервный адрес — второй. Не задавайте «Основной IP», если нужно распределять +новых клиентов между всеми адресами основного домена. В меню строки можно +назначить несколько основных серверов. Проверки здоровья DNS-пула включены всегда. + +Первое подключение нового IP выбирает доступный основной сервер по числу +соединений с учётом весов. Последующие подключения используют сохранённое +назначение. Если сервер недоступен, новые подключения могут перейти на другой +доступный сервер. Резерв предназначен для отказа всех основных, а не для +переполнения их нагрузки. Уже установленные TCP-соединения не переносятся. + +`24h` — срок неактивности записи, не периодическая ротация. При CGNAT один IP +закрепляет всех клиентов за ним. Персистентное назначение на резерв может +сохраняться после восстановления основных: это не гарантия немедленного возврата. + +Настройки сохраняются через API и при повторном открытии редактора. Старые +маршруты «Основной + резервные» без явных настроек распределения сохраняют +прежнюю генерацию. Для API сочетайте `servers[].backup` (без `balance_mode`, если +основных несколько) с `sticky_mode: source_table`, `sticky_ttl: 24h` и +`balance_algorithm: leastconn`. Для одного основного и последующих резервов +можно передать `balance_mode: failover`. + +Ограничения сочетаний резервных DNS-пулов и «Основного IP» не меняются. + +Используйте толерантность `0 %`. Управляемые агентом `leastping` и `leastconn` +с ненулевой толерантностью пока не разделяют основные и резервные серверы, +поэтому для этой топологии недоступны. Обычный `leastconn` работает в HAProxy +и не требует обновления агента. diff --git a/docs/install/README-NODE-AGENT.txt b/docs/install/README-NODE-AGENT.txt index 0fe8397..b2b04e7 100644 --- a/docs/install/README-NODE-AGENT.txt +++ b/docs/install/README-NODE-AGENT.txt @@ -2,13 +2,13 @@ NODEFLOW NODE AGENT — SIGNED RELEASE UPLOAD =========================================== Бинарники Agent — отдельные assets GitHub Release: - nodeflow-node-agent-2.1.0-linux-amd64 - nodeflow-node-agent-2.1.0-linux-arm64 + nodeflow-node-agent-2.2.0-linux-amd64 + nodeflow-node-agent-2.2.0-linux-arm64 install.sh публикует их в Panel сам. Для ручной загрузки скачайте файл и сверьте SHA-256 с digest, который GitHub показывает для asset'а: - v=2.1.0 f=nodeflow-node-agent-2.1.0-linux-amd64 + v=2.2.0 f=nodeflow-node-agent-2.2.0-linux-amd64 curl -fsSLO https://github.com/NodeFlow-dev/nodeflow/releases/download/v$v/$f curl -fsSL https://api.github.com/repos/NodeFlow-dev/nodeflow/releases/tags/v$v \ | jq -r --arg n "$f" '.assets[] | select(.name == $n) | .digest' @@ -17,7 +17,7 @@ install.sh публикует их в Panel сам. Для ручной загр В Panel откройте: Настройки → Node Agent (подписанные релизы) -Версия 2.1.0, ОС linux, архитектура amd64 или arm64 — по файлу. Выберите +Версия 2.2.0, ОС linux, архитектура amd64 или arm64 — по файлу. Выберите бинарник и нажмите «Загрузить и подписать». Затем назначьте релиз на странице нужной ноды. diff --git a/docs/install/index.html b/docs/install/index.html index 3caa430..6823e2a 100644 --- a/docs/install/index.html +++ b/docs/install/index.html @@ -34,7 +34,7 @@

Как установить Panel и подключить первую HAPro
Корень комплектаinstall.sh

Установщик и апгрейдер Panel, compose.release.yaml для готового образа ghcr.io/nodeflow-dev/nodeflow-panel, шаблон nodeflow.env.example, SHA256SUMS файлов комплекта.

scripts/ · configs/ · docs/scripts/install-node.sh

Скрипты PKI (init-mtls-pki.sh, init-update-signing-key.sh), ручная установка Agent и подготовка UFW ноды, systemd-юнит Agent, эта инструкция и примеры reverse proxy в docs/install/reverse-proxy/.

-
Бинарники Agent в комплект не входят. nodeflow-node-agent-2.1.0-linux-amd64 и nodeflow-node-agent-2.1.0-linux-arm64 лежат отдельными assets GitHub Release. install.sh скачивает оба, сверяет SHA-256 с digest, который GitHub публикует для asset'а, и публикует их в Panel.
+
Бинарники Agent в комплект не входят. nodeflow-node-agent-2.2.0-linux-amd64 и nodeflow-node-agent-2.2.0-linux-arm64 лежат отдельными assets GitHub Release. install.sh скачивает оба, сверяет SHA-256 с digest, который GitHub публикует для asset'а, и публикует их в Panel.
1

Подготовьте сервер Panel

Нужен домен, публичный IP и чистый Ubuntu или Debian.

@@ -158,7 +158,7 @@

Как установить Panel и подключить первую HAPro
5

Опубликуйте Node Agent

Без совместимого подписанного релиза Panel не начнёт установку ноды.

Автоматический установщик публикует Agent сам. Вручную:

-
  1. Скачайте nodeflow-node-agent-2.1.0-linux-amd64 (или -arm64) из assets GitHub Release.
  2. Сверьте sha256sum файла с digest asset'а на странице релиза (или в https://api.github.com/repos/NodeFlow-dev/nodeflow/releases/tags/v2.1.0, поле digest).
  3. Откройте Настройки → Node Agent (подписанные релизы).
  4. Версия: 2.1.0.
  5. ОС: linux.
  6. Архитектура: amd64 или arm64 — по файлу.
  7. Выберите файл и нажмите «Загрузить и подписать».
+
  1. Скачайте nodeflow-node-agent-2.2.0-linux-amd64 (или -arm64) из assets GitHub Release.
  2. Сверьте sha256sum файла с digest asset'а на странице релиза (или в https://api.github.com/repos/NodeFlow-dev/nodeflow/releases/tags/v2.2.0, поле digest).
  3. Откройте Настройки → Node Agent (подписанные релизы).
  4. Версия: 2.2.0.
  5. ОС: linux.
  6. Архитектура: amd64 или arm64 — по файлу.
  7. Выберите файл и нажмите «Загрузить и подписать».
Panel подписывает бинарник собственным Ed25519-ключом. Нода проверяет подпись, SHA-256 и порядок релиза; при неудачном запуске updater возвращает прежний Agent.
@@ -177,7 +177,7 @@

Как установить Panel и подключить первую HAPro
3. Проверить обновление
cd /opt/nodeflow
 sudo docker compose ps
 curl -fsS http://127.0.0.1:8080/healthz
-

После обновления Panel обновите Agent на нодах: «Настройки → Node Agent» → «Обновить до 2.1.0». Для нод, установленных версиями 1.0.x, см. раздел «Обновление с 1.0.x» в заметках к релизу 2.0.0 (однократный systemd drop-in для pipe-буферов ядра).

+

После обновления Panel обновите Agent на нодах: «Настройки → Node Agent» → «Обновить до 2.2.0». Для нод, установленных версиями 1.0.x, см. раздел «Обновление с 1.0.x» в заметках к релизу 2.0.0 (однократный systemd drop-in для pipe-буферов ядра).

8

Проверьте результат

Четыре короткие проверки после установки или обновления.

diff --git a/docs/releases/2.2.0.md b/docs/releases/2.2.0.md new file mode 100644 index 0000000..10377c9 --- /dev/null +++ b/docs/releases/2.2.0.md @@ -0,0 +1,39 @@ +# NodeFlow 2.2.0 + +Panel **2.2.0** · Node Agent **2.2.0** · HAProxy renderer **v21** + +Закрепление клиентов в DNS-пулах с резервными серверами, копирование маршрутов между нодами и библиотека шаблонов. Две миграции (000056, 000057). Agent не менялся. Спасибо @fesevu (#19, #20). + +## Новое + +- **Закрепление клиентов и `leastconn` в пулах с резервными серверами (#19).** Для DNS-пула с резервным (backup) сервером теперь доступны закрепление клиентов (sticky) и распределение `leastconn`. Новый failover-маршрут по умолчанию создаётся без закрепления. + - Ограничения: в режиме с резервным сервером `leastping`/`leastconn` с допуском (tolerance) больше 0 отклоняются; `leastping` вместе с закреплением по `source` тоже отклоняется. + - Клиент, закреплённый за резервным сервером, остаётся на нём до истечения записи закрепления (expire), даже когда основной сервер вернулся. +- **Копирование маршрутов между нодами (#20).** Маршрут можно скопировать на другую ноду. Копия создаётся выключенным черновиком — проверьте порты и адреса и включите её вручную. +- **Библиотека шаблонов маршрутов (#20).** Маршрут можно сохранить как шаблон и создавать из него новые маршруты на любой ноде. Шаблоны хранят `schema_version`. Миграция 000056 (`route_templates`). + +## Исправления + +- **Рендер старых failover-маршрутов не меняется.** Миграция 000057 сохраняет прежнее распределение для маршрутов с устаревшим `source_table`: такие маршруты один раз публикуются заново с идентичным конфигом HAProxy. Повторное сохранение старого failover-маршрута в UI тоже сохраняет его распределение. +- **Аудит:** копирование маршрута (`route.copy`) и действия с шаблонами (`route_template.*`) пишутся в журнал аудита. +- Понятные ошибки и имена копий/шаблонов; диалоги копирования и шаблонов оформлены в стиле панели. + +## Обновление с 2.1.0 + +1. **Panel.** Повторно запустите установщик: + + ```bash + curl -fsSL https://raw.githubusercontent.com/NodeFlow-dev/nodeflow/main/install.sh | sudo sh + ``` + + Вручную: в `/opt/nodeflow/.env` укажите `NODEFLOW_VERSION=2.2.0` и выполните `docker compose pull && docker compose up -d`. Миграции 000056 и 000057 применяет сам образ. +2. **Node Agent — необязательно.** Код Agent в 2.2.0 не менялся, всё работает с Agent 2.1.x. Для единообразия версий: «Настройки → Node Agent» → «Обновить до 2.2.0». + +Обновляетесь с 1.0.x — сначала прочитайте [заметки к релизу 2.0.0](2.0.0.md): там однократный drop-in systemd для pipe-буферов ядра. + +## Файлы релиза + +- `NodeFlow-Panel-2.2.0-Agent-2.2.0-install-kit.tar.gz` — install kit. +- `nodeflow-node-agent-2.2.0-linux-amd64`, `nodeflow-node-agent-2.2.0-linux-arm64` — бинарники Node Agent. + +Panel поставляется образом `ghcr.io/nodeflow-dev/nodeflow-panel:2.2.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 d67804a..803e3af 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "nodeflow-frontend", - "version": "2.1.0", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nodeflow-frontend", - "version": "2.1.0", + "version": "2.2.0", "dependencies": { "@mantine/core": "^8.3.10", "@mantine/hooks": "^8.3.10", diff --git a/frontend/package.json b/frontend/package.json index 1c13ae6..8d52542 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "nodeflow-frontend", "private": true, - "version": "2.1.0", + "version": "2.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/features/node-detail/NodeRoutesTable.tsx b/frontend/src/features/node-detail/NodeRoutesTable.tsx index bf87d0e..012256a 100644 --- a/frontend/src/features/node-detail/NodeRoutesTable.tsx +++ b/frontend/src/features/node-detail/NodeRoutesTable.tsx @@ -1,6 +1,6 @@ import { ActionIcon, Button, Menu, Modal, Pagination, Switch, TextInput, Tooltip } from '@mantine/core'; import { - IconAlertTriangle, IconChartLine, IconChevronDown, IconChevronUp, IconDotsVertical, IconEdit, IconFilter, IconGripVertical, IconLayoutList, IconPlus, IconSearch, IconTrash, + IconAlertTriangle, IconChartLine, IconChevronDown, IconChevronUp, IconDotsVertical, IconEdit, IconCopy, IconFilter, IconTemplate, IconGripVertical, IconLayoutList, IconPlus, IconSearch, IconTrash, } from '@tabler/icons-react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { DragEvent } from 'react'; @@ -18,6 +18,8 @@ import type { TrafficRange } from '../nodes/useNodesOverview'; import { api } from '../../lib/api'; import { isInteractiveRowTarget } from '../../lib/interaction'; +import { RouteCopyDialog, RouteSaveTemplateDialog } from '../routes/RouteLibraryDialog'; + type RouteFilter = 'all' | 'enabled' | 'draft' | 'failed'; interface NodeRoutesTableProps { @@ -163,6 +165,9 @@ interface RouteRowProps { reorderEnabled: boolean; reorderDisabledReason: string; onEdit: (route: RouteRecord) => void; + onDuplicate: (route: RouteRecord) => void; + onCopy: (route: RouteRecord) => void; + onSaveTemplate: (route: RouteRecord) => void; onToggle: (route: RouteRecord, enabled: boolean) => void; onStats: (route: RouteRecord) => void; onRequestDelete: (route: RouteRecord) => void; @@ -175,7 +180,7 @@ interface RouteRowProps { const RouteRow = memo(function RouteRow({ route, report, backend, servers, previousID, nextID, pending, dragging, dropTarget, ordering, reorderEnabled, reorderDisabledReason, - onEdit, onToggle, onStats, onRequestDelete, onMove, onDragStart, onDragEnd, onDragOver, onDrop, + onEdit, onDuplicate, onCopy, onSaveTemplate, onToggle, onStats, onRequestDelete, onMove, onDragStart, onDragEnd, onDragOver, onDrop, }: RouteRowProps) { const health = backendHealth(backend, servers); const limit = report?.limit_bytes ?? route.quota_bytes; @@ -246,6 +251,9 @@ const RouteRow = memo(function RouteRow({ } onClick={() => onStats(route)}>Статистика } onClick={() => onEdit(route)}>Редактировать + } onClick={() => onDuplicate(route)}>Дублировать маршрут + } onClick={() => onCopy(route)}>Копировать на ноды + } onClick={() => onSaveTemplate(route)}>Сохранить как шаблон } onClick={() => onRequestDelete(route)}>Удалить @@ -257,6 +265,8 @@ const RouteRow = memo(function RouteRow({ export function NodeRoutesTable({ nodeID, routes, traffic, metrics, busyRouteID, orderingRouteID = '', error, onToggle, onDelete, onReorder }: NodeRoutesTableProps) { const navigate = useNavigate(); const location = useLocation(); + const [copySource, setCopySource] = useState(null); + const [templateSource, setTemplateSource] = useState(null); const [query, setQuery] = useState(''); const [filter, setFilter] = useState('all'); const [deleteRoute, setDeleteRoute] = useState(null); @@ -482,6 +492,9 @@ export function NodeRoutesTable({ nodeID, routes, traffic, metrics, busyRouteID, reorderEnabled={reorderEnabled} reorderDisabledReason={reorderDisabledReason} onEdit={edit} + onDuplicate={route => navigate(`/nodes/${nodeID}/routes/new?copy_node=${nodeID}©_route=${route.id}${demo ? '&demo=1' : ''}`)} + onCopy={setCopySource} + onSaveTemplate={setTemplateSource} onToggle={toggle} onStats={openStats} onRequestDelete={requestDelete} @@ -518,6 +531,8 @@ export function NodeRoutesTable({ nodeID, routes, traffic, metrics, busyRouteID, /> )} + setCopySource(null)} nodeID={nodeID} source={copySource} /> + setTemplateSource(null)} source={templateSource} /> !deleting && setDeleteRoute(null)} title="Удалить маршрут?" classNames={{ content: 'nf-dialog', header: 'nf-dialog__header', body: 'nf-dialog__body' }}>

Маршрут {deleteRoute ? routeName(deleteRoute) : ''} будет сначала безопасно исключён из HAProxy, затем удалён после подтверждения Agent.

diff --git a/frontend/src/features/routes/RouteLibraryDialog.tsx b/frontend/src/features/routes/RouteLibraryDialog.tsx new file mode 100644 index 0000000..47163d6 --- /dev/null +++ b/frontend/src/features/routes/RouteLibraryDialog.tsx @@ -0,0 +1,235 @@ +import { ActionIcon, Button, Group, Menu, Modal, MultiSelect, Stack, Text, Textarea, TextInput } from '@mantine/core'; +import { IconCopy, IconDotsVertical, IconEdit, IconPlus, IconTemplate, IconTrash } from '@tabler/icons-react'; +import { useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; +import type { NodeRecord, RouteRecord } from '../../lib/contracts'; +import { copyRoutes, deleteTemplate, listTemplates, saveRouteTemplate, templateNodes, type CopyResult, type RouteTemplate } from './templates'; + +// Same Modal chrome as every other panel dialog (node settings, delete route). +const dialogClassNames = { content: 'nf-dialog', header: 'nf-dialog__header', body: 'nf-dialog__body' }; +const COPY_NOTE = 'Копии создаются выключенными; счётчики и история не переносятся.'; +const LISTENER_HINT = 'На другой ноде проверьте listener IP и Unix-сокеты перед включением.'; + +function errorText(e: unknown, fallback: string) { + return e instanceof Error ? e.message : fallback; +} + +function useDemoQuery() { + const location = useLocation(); + return new URLSearchParams(location.search).get('demo') === '1'; +} + +/** Target nodes, name and per-node results shared by «Копировать на ноды» and template apply. */ +function CopyTargets({ nodes, nodeID, targets, setTargets, name, setName, namePlaceholder, results, busy, onOpen }: { + nodes: NodeRecord[]; nodeID: string; targets: string[]; setTargets: (v: string[]) => void; + name: string; setName: (v: string) => void; namePlaceholder: string; + results: CopyResult[]; busy: boolean; onOpen: (result: CopyResult) => void; +}) { + const otherNode = targets.some((id) => id !== nodeID); + const nodeName = (id: string) => nodes.find((n) => n.id === id)?.name || id; + return <> + ({ value: n.id, label: n.id === nodeID ? `${n.name} (эта нода)` : n.name }))} + value={targets} onChange={setTargets} disabled={busy} + description={otherNode ? LISTENER_HINT : undefined} + /> + setName(e.currentTarget.value)} maxLength={80} disabled={busy} /> + {results.length > 0 &&
    + {results.map((r) =>
  • + {nodeName(r.node_id)} + {r.error || 'Черновик создан'} + {r.route && } +
  • )} +
} + ; +} + +/** «Копировать на ноды»: disabled copies of one route on selected nodes. */ +export function RouteCopyDialog({ opened, onClose, nodeID, source }: { + opened: boolean; onClose: () => void; nodeID: string; source: RouteRecord | null; +}) { + const navigate = useNavigate(); + const cache = useQueryClient(); + const demo = useDemoQuery(); + const [nodes, setNodes] = useState([]); + const [targets, setTargets] = useState([]); + const [name, setName] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [results, setResults] = useState([]); + useEffect(() => { + if (!opened) return; + let active = true; + setError(''); setResults([]); setTargets([]); setName(''); + templateNodes().then((n) => { if (active) setNodes(n); }).catch((e) => { if (active) setError(errorText(e, 'Список нод недоступен')); }); + return () => { active = false; }; + }, [opened, source?.id]); + const copy = async () => { + if (!source) return; + setBusy(true); setError(''); + try { + // An empty name keeps the source name; the copy on this node gets « (копия)». + const copied = await copyRoutes(source, targets, name.trim()); + setResults(copied); + // Keep failed targets selected, so a retry cannot duplicate successful copies. + setTargets(copied.filter((r) => r.error).map((r) => r.node_id)); + await cache.invalidateQueries({ queryKey: ['node-detail'] }); + } catch (e) { setError(errorText(e, 'Копии не созданы')); } finally { setBusy(false); } + }; + const done = results.length > 0 && targets.length === 0; + return {} : onClose} title="Копировать на ноды" size="md" closeOnClickOutside={!busy} closeOnEscape={!busy} classNames={dialogClassNames}> + + {COPY_NOTE} + { onClose(); navigate(`/nodes/${r.node_id}/routes/${r.route!.id}/edit${demo ? '?demo=1' : ''}`); }} + /> + {error &&
{error}
} + + + {!done && } + +
+
; +} + +/** «Сохранить как шаблон»: name and an optional short description. */ +export function RouteTemplateSaveDialog({ opened, onClose, title = 'Сохранить как шаблон', initialName, initialDescription = '', onSave }: { + opened: boolean; onClose: () => void; title?: string; initialName: string; initialDescription?: string; + onSave: (name: string, description: string) => Promise; +}) { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + useEffect(() => { + if (!opened) return; + setName(initialName); setDescription(initialDescription); setError(''); + }, [opened]); // eslint-disable-line react-hooks/exhaustive-deps + const save = async () => { + setBusy(true); setError(''); + try { await onSave(name.trim(), description.trim()); } catch (e) { setError(errorText(e, 'Шаблон не сохранён')); } finally { setBusy(false); } + }; + return {} : onClose} title={title} size="md" closeOnClickOutside={!busy} closeOnEscape={!busy} classNames={dialogClassNames}> + + Изменение шаблона не меняет созданные из него маршруты. + setName(e.currentTarget.value)} maxLength={80} disabled={busy} data-autofocus required /> +