Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-состояние в шаблоне отсутствует.
39 changes: 39 additions & 0 deletions docs/features/route-templates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Копирование маршрутов и шаблоны

В меню строки маршрута доступны:

- **Дублировать маршрут** — открыть заполненный редактор нового маршрута на
этой ноде. Имя получает суффикс «копия». Изменения исходного маршрута не
сохраняются в копию автоматически.
- **Копировать на ноды / сохранить шаблон** — выбрать одну или несколько нод
и создать на них выключенные копии либо сохранить настройки в библиотеку.

Кнопка **Шаблоны маршрутов** на странице ноды открывает общую библиотеку.
Выберите шаблон и целевые ноды, чтобы создать маршруты сразу на нескольких
нодах. Для предварительной настройки нажмите **Настроить для этой ноды**.
Шаблон можно отредактировать полным редактором маршрута или удалить.
Сохранить текущие настройки как новый шаблон можно и из редактора маршрута.

Шаблон — сохранённый снимок настроек. Созданные маршруты независимы:
изменение или удаление шаблона не меняет их. Автоматической синхронизации
общего маршрута между нодами нет.

Переносятся listener, SNI, серверы и их роли, DNS-пулы, веса, закрепление,
PROXY protocol, лимиты, плавный ввод и ручные backend-директивы. Не переносятся
идентификаторы, история, применённые ревизии, состояние публикации, соединения,
таблицы закрепления и использованный трафик. Новые маршруты всегда выключены.

Перед включением проверьте привязанный listener IP, пути Unix-сокетов,
доступность бэкендов и поддержку параметров агентом целевой ноды. Конфликты
SNI и listener проверяются штатной процедурой включения. Копирование не
заменяет существующие маршруты и не публикует конфигурацию HAProxy.

При копировании на несколько нод результат показывается для каждой.
Успешные копии сохраняются даже при ошибке на другой ноде. Для повторной
попытки остаются выбраны только ноды с ошибками. Если ответ потерян из-за
сети, проверьте списки маршрутов перед повтором: запрос копирования не
является идемпотентным.

Шаблоны хранятся в PostgreSQL (миграция 000056) и доступны только через
административную авторизацию. Изменение, удаление и применение требуют
актуальную версию шаблона. Устаревшая операция получает 409.
14 changes: 12 additions & 2 deletions frontend/src/features/node-detail/NodeRoutesTable.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -246,6 +250,8 @@ const RouteRow = memo(function RouteRow({
<Menu.Item leftSection={<IconChartLine size={16} />} onClick={() => onStats(route)}>Статистика</Menu.Item>
<Menu.Item leftSection={<IconEdit size={16} />} onClick={() => onEdit(route)}>Редактировать</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => onDuplicate(route)}>Дублировать маршрут</Menu.Item>
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => onCopy(route)}>Копировать на ноды / сохранить шаблон</Menu.Item>
<Menu.Item color="red" leftSection={<IconTrash size={16} />} onClick={() => onRequestDelete(route)}>Удалить</Menu.Item>
</Menu.Dropdown>
</Menu>
Expand All @@ -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<RouteRecord | null>(null);
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<RouteFilter>('all');
const [deleteRoute, setDeleteRoute] = useState<RouteRecord | null>(null);
Expand Down Expand Up @@ -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}&copy_route=${route.id}${demo ? '&demo=1' : ''}`)}
onCopy={setCopySource}
onToggle={toggle}
onStats={openStats}
onRequestDelete={requestDelete}
Expand Down Expand Up @@ -518,6 +527,7 @@ export function NodeRoutesTable({ nodeID, routes, traffic, metrics, busyRouteID,
/>
)}

<RouteLibraryDialog opened={Boolean(copySource)} onClose={() => setCopySource(null)} nodeID={nodeID} source={copySource || undefined} />
<Modal opened={Boolean(deleteRoute)} onClose={() => !deleting && setDeleteRoute(null)} title="Удалить маршрут?" classNames={{ content: 'nf-dialog', header: 'nf-dialog__header', body: 'nf-dialog__body' }}>
<div className="nf-confirm-dialog">
<p>Маршрут <strong>{deleteRoute ? routeName(deleteRoute) : ''}</strong> будет сначала безопасно исключён из HAProxy, затем удалён после подтверждения Agent.</p>
Expand Down
96 changes: 96 additions & 0 deletions frontend/src/features/routes/RouteLibraryDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<NodeRecord[]>([]);
const [templates, setTemplates] = useState<RouteTemplate[]>([]);
const [selected, setSelected] = useState<string | null>(null);
const [targets, setTargets] = useState<string[]>([]);
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<CopyResult[]>([]);
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<void>) => {
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 <Modal opened={opened} onClose={busy ? () => {} : onClose} title={source ? `Копирование маршрута «${source.name}»` : 'Шаблоны маршрутов'} size="lg">
<Stack gap="md">
<Text size="sm" c="dimmed">Копии создаются выключенными. Настройки сохраняются, но состояние соединений, счётчики и история не переносятся.</Text>
{error && <Alert color="red">{error}</Alert>}
{message && <Alert color="green">{message}</Alert>}
{!source && <>
<Select label="Шаблон" placeholder={templates.length ? 'Выберите шаблон' : 'Нет шаблонов — сохраните любой маршрут как шаблон'} searchable
data={templates.map(t => ({ value: t.id, label: t.name }))} value={selected} disabled={busy}
onChange={id => { setSelected(id); setName(String(templates.find(t => t.id === id)?.config.name || '')); setConfirmDelete(false); setResults([]); }} />
{template && <>
<Text size="sm">{template.description || 'Без описания'}</Text>
<Text size="sm" c="dimmed">{String(template.config.listener_ip || '*')}:{String(template.config.listener_port || 443)} · {Array.isArray(template.config.snis) ? template.config.snis.join(', ') : 'Фолбэк'} · версия {template.version}</Text>
<Group>
<Button variant="default" disabled={busy} onClick={() => { onClose(); navigate(`/nodes/${nodeID}/routes/new?template=${template.id}${query}`); }}>Настроить для этой ноды</Button>
<Button variant="default" disabled={busy} onClick={() => { onClose(); navigate(`/nodes/${nodeID}/routes/new?template=${template.id}&edit_template=1${query}`); }}>Редактировать настройки</Button>
<Button color="red" variant="subtle" disabled={busy} onClick={() => {
if (!confirmDelete) { setConfirmDelete(true); return; }
void perform(async () => { await deleteTemplate(template); setTemplates(await listTemplates()); setSelected(null); setConfirmDelete(false); });
}}>{confirmDelete ? 'Подтвердить удаление шаблона' : 'Удалить шаблон'}</Button>
</Group>
</>}
</>}
{current && <>
<MultiSelect label="Целевые ноды" maxValues={100} placeholder="Выберите одну или несколько нод" searchable
data={nodes.map(n => ({ value: n.id, label: n.name }))} value={targets} onChange={setTargets} disabled={busy} />
<TextInput label="Имя новых маршрутов" value={name} onChange={e => setName(e.currentTarget.value)} maxLength={80} disabled={busy} />
<Text size="sm" c="dimmed">Проверьте listener IP, локальные Unix-сокеты, доступность бэкендов и версию агента на целевых нодах. При включении штатная проверка обнаружит конфликты SNI и портов.</Text>
<Button loading={busy} disabled={!targets.length || !name.trim()} onClick={() => void apply()}>Создать копии на выбранных нодах</Button>
</>}
{results.map(r => <Group key={r.node_id} justify="space-between">
<Text size="sm">{nodes.find(n => n.id === r.node_id)?.name || r.node_id}: {r.error || 'Черновик создан'}</Text>
{r.route && <Button size="xs" variant="subtle" onClick={() => { onClose(); navigate(`/nodes/${r.node_id}/routes/${r.route!.id}/edit${query ? '?demo=1' : ''}`); }}>Открыть</Button>}
</Group>)}
{source && <>
<Text fw={600}>Сохранить настройки в общую библиотеку</Text>
<TextInput label="Название шаблона" value={templateName} onChange={e => setTemplateName(e.currentTarget.value)} maxLength={80} disabled={busy} />
<Textarea label="Описание шаблона" value={description} onChange={e => setDescription(e.currentTarget.value)} maxLength={1000} disabled={busy} />
<Button variant="default" loading={busy} disabled={!templateName.trim()} onClick={() => void perform(async () => {
const value = await saveRouteTemplate(source, templateName, description); setTemplates(await listTemplates()); setMessage(`Шаблон «${value.name}» сохранён. Изменения шаблона не меняют существующие маршруты.`);
})}>Сохранить как шаблон</Button>
</>}
</Stack>
</Modal>;
}
14 changes: 14 additions & 0 deletions frontend/src/features/routes/templateModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { RouteRecord } from '../../lib/contracts';
import { routeToDraft, type RouteDraft } from './model';

export interface RouteTemplate {
id: string;
name: string;
description: string;
config: Record<string, unknown>;
version: number;
}

export function templateDraft(template: RouteTemplate): RouteDraft {
return routeToDraft({ ...template.config, id: '', node_id: '', version: 1, enabled: false } as unknown as RouteRecord);
}
Loading
Loading