diff --git a/README.md b/README.md index ba9533e..2c870b1 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,7 @@ go test ./... - Заметки к релизам [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 и Advanced-редактор](docs/features/haproxy-configuration.md) - [Архитектура](docs/architecture.md) - [Первичная установка ноды](docs/bootstrap-flow.md) - [Самообновление Agent](docs/node-self-update.md) diff --git a/docs/api-contract.md b/docs/api-contract.md index 209b662..2956532 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -43,6 +43,7 @@ once and atomically bound to the verified leaf before subsequent use. | `POST` | `/api/v1/nodes/{node_id}/config-revisions/from-routes` | Render enabled routes and create an immutable revision without assigning it. | | `GET` | `/api/v1/nodes/{node_id}/config-revisions/{revision}` | Read one immutable configuration revision. | | `GET` | `/api/v1/nodes/{node_id}/config-state` | Read desired/actual revision and convergence state. | +| `POST` | `/api/v1/nodes/{node_id}/generated-config` | Atomically resume generated configuration from current routes and node settings. | | `PUT` | `/api/v1/nodes/{node_id}/desired-revision` | Assign any existing revision as desired state. | | `GET, POST` | `/api/v1/agent-releases` | List or stream/upload and sign an immutable Agent release. | | `DELETE` | `/api/v1/agent-releases/{release_id}` | Delete only an unused release; installed, assigned and rollback-required releases return `409 release_in_use`. | @@ -672,9 +673,11 @@ support. `POST /api/v1/nodes/{node_id}/render-config` has no request body. It returns the complete config, SHA-256, renderer version, counts, warnings and stable runtime -names. Disabled routes are excluded. An empty enabled route set returns -`422 no_enabled_routes`; a defensively detected inconsistent stored route set -returns `422 invalid_route_set`. +names. Disabled routes are excluded. An empty enabled route set returns a +base configuration without listeners, suitable for the Advanced editor. A +defensively detected inconsistent stored route set returns `422 invalid_route_set`. +Node-wide `global` and `defaults` settings and manual revision behavior are +described in [HAProxy configuration](features/haproxy-configuration.md). The current renderer rejects wildcard/specific bind overlaps, caps enabled routes at 1024 and emits one ACL per SNI route, with up to 32 SNI values per `acl` line diff --git a/docs/features/haproxy-configuration.md b/docs/features/haproxy-configuration.md new file mode 100644 index 0000000..71a886f --- /dev/null +++ b/docs/features/haproxy-configuration.md @@ -0,0 +1,77 @@ +# Per-node HAProxy configuration + +Open a node and select **Конфигурация HAProxy**. + +## Global / defaults + +The form configures HAProxy for every generated route on that node: + +| Setting | HAProxy directive | Default | +| --- | --- | --- | +| Maximum connections | `global maxconn` | HAProxy automatic default | +| Threads | `global nbthread` | HAProxy automatic default | +| Connect timeout | `defaults timeout connect` | `5s` | +| Client timeout | `defaults timeout client` | `15m` | +| Server timeout | `defaults timeout server` | `15m` | + +Empty fields preserve existing defaults. Settings are stored in +`nodes.metadata.haproxy_settings`; clients omitting this key preserve it. +An empty object resets tuning. API validation rejects unknown fields, +non-integral limits, out-of-range values and malformed durations. + +Changing these settings republishes an active route-lifecycle configuration +in the same database transaction. Nodes without such a configuration retain +the settings for their next generated revision. While an advanced revision +is assigned, changing settings does not alter its text. + +## Advanced + +1. Configure routes and save global/defaults settings. +2. Choose **Загрузить из UI** to populate the editor, or load an existing revision. + A node with no enabled routes can generate a base configuration. +3. Edit `haproxy.cfg` and choose **Сохранить версию**. Saving creates an immutable + draft and does not deploy it. +4. Choose **Применить версию** to assign the saved revision to the node. +5. Watch desired/applied revision numbers, status and the last application error. + The agent validates with HAProxy before applying. Saving or assigning a + revision is not confirmation of successful application. + +Advanced revisions use `metadata.source=advanced_editor`. While one is assigned, +route operations requiring publication fail with HTTP 409 +`advanced_config_active`; the route mutation and revision publication roll back +together. Draft-only changes can still be saved. Node fact changes do not +replace advanced revisions. Assignment and route publication serialize on the +same node lock. + +**Вернуться к сборке из UI** calls `POST /api/v1/nodes/{id}/generated-config`. +It renders current saved routes and settings, creates a revision, updates route +deployment tracking and assigns it in one transaction. It does not reuse a +potentially stale preview. Previous manual revisions remain in history. + +The editor deliberately does not parse arbitrary HAProxy text back into route +forms. Preserve the agent's admin socket and generated runtime names when using +NodeFlow runtime metrics. Quotas, routing statistics and firewall planning are +based on managed route intent; arbitrary manual listeners/backends do not +acquire those features automatically. Certificates and referenced files must +already exist on the node. Configurations are limited to 512 KiB. Avoid storing +private keys directly in configuration text. + +## Verification + +- Frontend: `cd frontend && npm ci && npm run build && npm test`. +- Linux: `go test ./internal/panel ./internal/agent`. +- PostgreSQL integration: apply migrations to an isolated database, set + `NODEFLOW_TEST_DATABASE_URL`, then run + `go test ./internal/panel -run 'TestPGStoreHAProxySettingsAndAdvancedMode'`. + +The integration test covers republishing tuned settings, old-client omission, +manual-mode publication rejection and rollback, preservation of manual text, +and explicit resumption of generated configuration. + +## Screenshots + +The screenshots use a local test panel and synthetic configuration values. + +![Per-node global and defaults settings](../images/haproxy-settings.png) + +![Advanced configuration editor](../images/haproxy-advanced.png) diff --git a/docs/images/haproxy-advanced.png b/docs/images/haproxy-advanced.png new file mode 100644 index 0000000..619320f Binary files /dev/null and b/docs/images/haproxy-advanced.png differ diff --git a/docs/images/haproxy-settings.png b/docs/images/haproxy-settings.png new file mode 100644 index 0000000..ab0e3dd Binary files /dev/null and b/docs/images/haproxy-settings.png differ diff --git a/frontend/src/features/node-detail/HAProxyConfiguration.tsx b/frontend/src/features/node-detail/HAProxyConfiguration.tsx new file mode 100644 index 0000000..2087782 --- /dev/null +++ b/frontend/src/features/node-detail/HAProxyConfiguration.tsx @@ -0,0 +1,126 @@ +import { Alert, Button, Group, NumberInput, Select, SimpleGrid, Stack, Tabs, Text, Textarea, TextInput } from '@mantine/core'; +import { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { api, APIError } from '../../lib/api'; +import type { NodeRecord } from '../../lib/contracts'; + +type Settings = { max_connections?: number; threads?: number; timeout_connect?: string; timeout_client?: string; timeout_server?: string }; +type Revision = { revision: number; config: string; note?: string; metadata: Record }; +type ConfigState = { desired_revision: number | null; actual_revision: number | null; state: string; last_error?: string }; + +export function HAProxyConfiguration({ node, demo, onSaved, onEditorState }: { node: NodeRecord; demo: boolean; onSaved: (node: NodeRecord) => void; onEditorState: (dirty: boolean, busy: boolean) => void }) { + const base = `/api/v1/nodes/${node.id}`; + const [settings, setSettings] = useState((node.metadata?.haproxy_settings as Settings) ?? {}); + const [config, setConfig] = useState(''); + const [savedConfig, setSavedConfig] = useState(''); + const [revision, setRevision] = useState(null); + const [note, setNote] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [message, setMessage] = useState(''); + const dirty = config !== savedConfig; + const settingsDirty = JSON.stringify(settings) !== JSON.stringify((node.metadata?.haproxy_settings as Settings) ?? {}); + useEffect(() => { onEditorState(dirty || settingsDirty, busy); }, [dirty, settingsDirty, busy, onEditorState]); + const query = useQuery({ + queryKey: ['haproxy-config', node.id], enabled: !demo, + queryFn: async () => { + const revisions = await api(`${base}/config-revisions`); + const state = revisions.length ? await api(`${base}/config-state`).catch((error: unknown) => { + if (error instanceof APIError && error.status === 404) return null; + throw error; + }) : null; + const active = revisions.find((r) => r.revision === state?.desired_revision) + ?? (state?.desired_revision ? await api(`${base}/config-revisions/${state.desired_revision}`) : null); + return { revisions, state, active }; + }, refetchInterval: 5000, + }); + const state = query.data?.state; + const manual = query.data?.active?.metadata.source === 'advanced_editor'; + useEffect(() => { + if (!dirty && !settingsDirty) return; + const warn = (event: BeforeUnloadEvent) => { event.preventDefault(); }; + window.addEventListener('beforeunload', warn); + return () => window.removeEventListener('beforeunload', warn); + }, [dirty, settingsDirty]); + const run = async (action: () => Promise) => { + setBusy(true); setError(''); setMessage(''); + try { await action(); } catch (error) { setError(error instanceof Error ? error.message : 'Операция не выполнена'); } + finally { setBusy(false); } + }; + const replace = async (action: () => Promise) => { + if (dirty && !window.confirm('Заменить несохранённый текст конфигурации?')) return; + await run(action); + }; + const loadRevision = async (value: string) => { + const loaded = await api(`${base}/config-revisions/${value}`); + setConfig(loaded.config); setSavedConfig(loaded.config); setRevision(value); setNote(loaded.note ?? ''); + }; + const saveSettings = () => run(async () => { + // Read current metadata so opening this editor does not overwrite newer node settings. + const current = await api(base); + const updated = await api(base, { method: 'PUT', body: JSON.stringify({ name: current.name, address: current.address, metadata: { ...current.metadata, haproxy_settings: settings } }) }); + onSaved(updated); setMessage(manual ? 'Параметры сохранены для следующей сборки из маршрутов. Ручной конфиг не изменён.' : (state?.desired_revision ? 'Параметры сохранены. Активная конфигурация из маршрутов будет обновлена агентом.' : 'Параметры сохранены для следующей сборки из маршрутов.')); + await query.refetch(); + }); + const generate = () => { + if (settingsDirty) { setError('Сначала сохраните параметры Global / defaults, чтобы они вошли в сборку.'); return; } + return replace(async () => { + const rendered = await api<{config: string}>(`${base}/render-config`, { method: 'POST' }); + setConfig(rendered.config); setSavedConfig(''); setRevision(null); setNote(''); + setMessage('Загружена сборка из сохранённых маршрутов и параметров. Текст можно редактировать и сохранить как ручную версию.'); + }); + }; + const saveDraft = () => run(async () => { + const created = await api(`${base}/config-revisions`, { method: 'POST', body: JSON.stringify({config, note, metadata: {source: 'advanced_editor'}}) }); + setRevision(String(created.revision)); setSavedConfig(config); setMessage(`Версия ${created.revision} сохранена. Для отправки на ноду нажмите «Применить версию».`); await query.refetch(); + }); + const assign = () => run(async () => { + await api(`${base}/desired-revision`, {method: 'PUT', body: JSON.stringify({revision: Number(revision)})}); + setMessage('Версия назначена. Агент проверит конфиг перед применением; результат появится ниже.'); await query.refetch(); + }); + const resume = async () => { + if (settingsDirty) { setError('Сначала сохраните параметры Global / defaults.'); return; } + if (!window.confirm('Вернуться к конфигурации из сохранённых маршрутов? Ручная версия останется в истории.')) return; + await replace(async () => { + const created = await api(`${base}/generated-config`, {method: 'POST'}); + setConfig(created.config); setSavedConfig(created.config); setRevision(String(created.revision)); setNote(created.note ?? ''); + setMessage('Назначена конфигурация из маршрутов. Ожидается применение агентом.'); await query.refetch(); + }); + }; + return + {demo && Демонстрация интерфейса. Сохранение и применение доступны при подключении к Panel API.} + Режим: {manual ? 'Advanced — ручная конфигурация' : 'Сборка из маршрутов'}. Назначена: {state?.desired_revision ?? '—'} · Применена: {state?.actual_revision ?? '—'} · Статус: {state?.state ?? 'нет данных'} + {state?.last_error && {state.last_error}} + {(error || query.error) && {error || query.error?.message}} + {message && {message}} + + Global / defaultsAdvanced + + Параметры HAProxy для всех маршрутов этой ноды. Пустые поля используют значения по умолчанию. Сохранение обновляет активную сборку из маршрутов. + + setSettings({...settings, max_connections: typeof v === 'number' ? v : undefined})} /> + setSettings({...settings, threads: typeof v === 'number' ? v : undefined})} /> + + + {(['connect', 'client', 'server'] as const).map((key) => setSettings({...settings, [`timeout_${key}`]: e.currentTarget.value})} />)} + + + + + При применении ручной версии автоматическая публикация маршрутов блокируется до возврата к сборке из UI. Произвольный конфиг не синхронизируется обратно в формы маршрутов. Runtime-метрики, квоты и управление firewall зависят от структуры конфигурации. + + + + + +