diff --git a/docs/api-contract.md b/docs/api-contract.md index 3c10392..5ec5eb5 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -1064,3 +1064,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/features/route-templates.md b/docs/features/route-templates.md new file mode 100644 index 0000000..a72b2c6 --- /dev/null +++ b/docs/features/route-templates.md @@ -0,0 +1,39 @@ +# Копирование маршрутов и шаблоны + +В меню строки маршрута доступны: + +- **Дублировать маршрут** — открыть заполненный редактор нового маршрута на + этой ноде. Имя получает суффикс «копия». Изменения исходного маршрута не + сохраняются в копию автоматически. +- **Копировать на ноды / сохранить шаблон** — выбрать одну или несколько нод + и создать на них выключенные копии либо сохранить настройки в библиотеку. + +Кнопка **Шаблоны маршрутов** на странице ноды открывает общую библиотеку. +Выберите шаблон и целевые ноды, чтобы создать маршруты сразу на нескольких +нодах. Для предварительной настройки нажмите **Настроить для этой ноды**. +Шаблон можно отредактировать полным редактором маршрута или удалить. +Сохранить текущие настройки как новый шаблон можно и из редактора маршрута. + +Шаблон — сохранённый снимок настроек. Созданные маршруты независимы: +изменение или удаление шаблона не меняет их. Автоматической синхронизации +общего маршрута между нодами нет. + +Переносятся listener, SNI, серверы и их роли, DNS-пулы, веса, закрепление, +PROXY protocol, лимиты, плавный ввод и ручные backend-директивы. Не переносятся +идентификаторы, история, применённые ревизии, состояние публикации, соединения, +таблицы закрепления и использованный трафик. Новые маршруты всегда выключены. + +Перед включением проверьте привязанный listener IP, пути Unix-сокетов, +доступность бэкендов и поддержку параметров агентом целевой ноды. Конфликты +SNI и listener проверяются штатной процедурой включения. Копирование не +заменяет существующие маршруты и не публикует конфигурацию HAProxy. + +При копировании на несколько нод результат показывается для каждой. +Успешные копии сохраняются даже при ошибке на другой ноде. Для повторной +попытки остаются выбраны только ноды с ошибками. Если ответ потерян из-за +сети, проверьте списки маршрутов перед повтором: запрос копирования не +является идемпотентным. + +Шаблоны хранятся в PostgreSQL (миграция 000056) и доступны только через +административную авторизацию. Изменение, удаление и применение требуют +актуальную версию шаблона. Устаревшая операция получает 409. diff --git a/frontend/src/features/node-detail/NodeRoutesTable.tsx b/frontend/src/features/node-detail/NodeRoutesTable.tsx index bf87d0e..d1bd4ba 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, 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 { RouteLibraryDialog } from '../routes/RouteLibraryDialog'; + type RouteFilter = 'all' | 'enabled' | 'draft' | 'failed'; interface NodeRoutesTableProps { @@ -163,6 +165,8 @@ interface RouteRowProps { reorderEnabled: boolean; reorderDisabledReason: string; onEdit: (route: RouteRecord) => void; + onDuplicate: (route: RouteRecord) => void; + onCopy: (route: RouteRecord) => void; onToggle: (route: RouteRecord, enabled: boolean) => void; onStats: (route: RouteRecord) => void; onRequestDelete: (route: RouteRecord) => void; @@ -175,7 +179,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, onToggle, onStats, onRequestDelete, onMove, onDragStart, onDragEnd, onDragOver, onDrop, }: RouteRowProps) { const health = backendHealth(backend, servers); const limit = report?.limit_bytes ?? route.quota_bytes; @@ -246,6 +250,8 @@ const RouteRow = memo(function RouteRow({ } onClick={() => onStats(route)}>Статистика } onClick={() => onEdit(route)}>Редактировать + } onClick={() => onDuplicate(route)}>Дублировать маршрут + } onClick={() => onCopy(route)}>Копировать на ноды / сохранить шаблон } onClick={() => onRequestDelete(route)}>Удалить @@ -257,6 +263,7 @@ 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 [query, setQuery] = useState(''); const [filter, setFilter] = useState('all'); const [deleteRoute, setDeleteRoute] = useState(null); @@ -482,6 +489,8 @@ 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} onToggle={toggle} onStats={openStats} onRequestDelete={requestDelete} @@ -518,6 +527,7 @@ export function NodeRoutesTable({ nodeID, routes, traffic, metrics, busyRouteID, /> )} + setCopySource(null)} nodeID={nodeID} source={copySource || undefined} /> !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..571cbfd --- /dev/null +++ b/frontend/src/features/routes/RouteLibraryDialog.tsx @@ -0,0 +1,96 @@ +import { Alert, Button, Group, Modal, MultiSelect, Select, Stack, Text, Textarea, TextInput } from '@mantine/core'; +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'; + +export function RouteLibraryDialog({ opened, onClose, nodeID, source }: { + opened: boolean; onClose: () => void; nodeID: string; source?: RouteRecord; +}) { + const navigate = useNavigate(); + const location = useLocation(); + const cache = useQueryClient(); + const [nodes, setNodes] = useState([]); + const [templates, setTemplates] = useState([]); + const [selected, setSelected] = useState(null); + const [targets, setTargets] = useState([]); + const [name, setName] = useState(''); + const [templateName, setTemplateName] = useState(''); + const [description, setDescription] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [message, setMessage] = useState(''); + const [results, setResults] = useState([]); + const [confirmDelete, setConfirmDelete] = useState(false); + const template = templates.find(t => t.id === selected); + const current = source || template; + const query = new URLSearchParams(location.search).get('demo') === '1' ? '&demo=1' : ''; + useEffect(() => { + if (!opened) return; + let active = true; + setError(''); setMessage(''); setResults([]); setSelected(null); setConfirmDelete(false); + setTargets([nodeID]); setName(source?.name || ''); setTemplateName(source?.name || ''); setDescription(''); setBusy(true); + Promise.all([templateNodes(), listTemplates()]).then(([n, t]) => { + if (active) { setNodes(n); setTemplates(t); } + }).catch(e => { if (active) setError(e.message); }).finally(() => { if (active) setBusy(false); }); + return () => { active = false; }; + }, [opened, nodeID, source?.id]); + const perform = async (action: () => Promise) => { + setBusy(true); setError(''); setMessage(''); + try { await action(); } catch (e) { setError(e instanceof Error ? e.message : 'Операция не выполнена'); } + finally { setBusy(false); } + }; + const apply = () => perform(async () => { + if (!current) return; + const copied = await copyRoutes(current, targets, name); + setResults(copied); + // Keep failed targets selected, so retry cannot duplicate successful copies. + setTargets(copied.filter(r => r.error).map(r => r.node_id)); + await cache.invalidateQueries({ queryKey: ['node-detail'] }); + setMessage(`Создано черновиков: ${copied.filter(r => r.route).length}. Включите их после проверки настроек на целевых нодах.`); + }); + return {} : onClose} title={source ? `Копирование маршрута «${source.name}»` : 'Шаблоны маршрутов'} size="lg"> + + Копии создаются выключенными. Настройки сохраняются, но состояние соединений, счётчики и история не переносятся. + {error && {error}} + {message && {message}} + {!source && <> +