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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 6 additions & 3 deletions docs/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions docs/features/haproxy-configuration.md
Original file line number Diff line number Diff line change
@@ -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)
Binary file added docs/images/haproxy-advanced.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/haproxy-settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
126 changes: 126 additions & 0 deletions frontend/src/features/node-detail/HAProxyConfiguration.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown> };
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<Settings>((node.metadata?.haproxy_settings as Settings) ?? {});
const [config, setConfig] = useState('');
const [savedConfig, setSavedConfig] = useState('');
const [revision, setRevision] = useState<string | null>(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<Revision[]>(`${base}/config-revisions`);
const state = revisions.length ? await api<ConfigState>(`${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<Revision>(`${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<void>) => {
setBusy(true); setError(''); setMessage('');
try { await action(); } catch (error) { setError(error instanceof Error ? error.message : 'Операция не выполнена'); }
finally { setBusy(false); }
};
const replace = async (action: () => Promise<void>) => {
if (dirty && !window.confirm('Заменить несохранённый текст конфигурации?')) return;
await run(action);
};
const loadRevision = async (value: string) => {
const loaded = await api<Revision>(`${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<NodeRecord>(base);
const updated = await api<NodeRecord>(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<Revision>(`${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<Revision>(`${base}/generated-config`, {method: 'POST'});
setConfig(created.config); setSavedConfig(created.config); setRevision(String(created.revision)); setNote(created.note ?? '');
setMessage('Назначена конфигурация из маршрутов. Ожидается применение агентом.'); await query.refetch();
});
};
return <Stack>
{demo && <Alert>Демонстрация интерфейса. Сохранение и применение доступны при подключении к Panel API.</Alert>}
<Text size="sm">Режим: {manual ? 'Advanced — ручная конфигурация' : 'Сборка из маршрутов'}. Назначена: {state?.desired_revision ?? '—'} · Применена: {state?.actual_revision ?? '—'} · Статус: {state?.state ?? 'нет данных'}</Text>
{state?.last_error && <Alert color="red" title="Ошибка применения">{state.last_error}</Alert>}
{(error || query.error) && <Alert color="red">{error || query.error?.message}</Alert>}
{message && <Alert color="teal">{message}</Alert>}
<Tabs defaultValue="settings">
<Tabs.List><Tabs.Tab value="settings">Global / defaults</Tabs.Tab><Tabs.Tab value="advanced">Advanced</Tabs.Tab></Tabs.List>
<Tabs.Panel value="settings" pt="md"><Stack>
<Text size="sm" c="dimmed">Параметры HAProxy для всех маршрутов этой ноды. Пустые поля используют значения по умолчанию. Сохранение обновляет активную сборку из маршрутов.</Text>
<SimpleGrid cols={{base: 1, sm: 2}}>
<NumberInput label="Максимум соединений · maxconn" placeholder="Автоматически" min={1} max={10000000} allowDecimal={false} value={settings.max_connections || ''} onChange={(v) => setSettings({...settings, max_connections: typeof v === 'number' ? v : undefined})} />
<NumberInput label="Потоки · nbthread" placeholder="Автоматически" min={1} max={256} allowDecimal={false} value={settings.threads || ''} onChange={(v) => setSettings({...settings, threads: typeof v === 'number' ? v : undefined})} />
</SimpleGrid>
<SimpleGrid cols={{base: 1, sm: 3}}>
{(['connect', 'client', 'server'] as const).map((key) => <TextInput key={key} label={`timeout ${key}`} placeholder={key === 'connect' ? '5s' : '15m'} description="Положительное значение с единицей ms, s, m или h" value={settings[`timeout_${key}`] ?? ''} onChange={(e) => setSettings({...settings, [`timeout_${key}`]: e.currentTarget.value})} />)}
</SimpleGrid>
<Button disabled={demo || busy || query.isLoading || query.isError} loading={busy} onClick={saveSettings}>Сохранить параметры</Button>
</Stack></Tabs.Panel>
<Tabs.Panel value="advanced" pt="md"><Stack>
<Alert color="yellow">При применении ручной версии автоматическая публикация маршрутов блокируется до возврата к сборке из UI. Произвольный конфиг не синхронизируется обратно в формы маршрутов. Runtime-метрики, квоты и управление firewall зависят от структуры конфигурации.</Alert>
<Group>
<Button variant="default" disabled={demo || busy} onClick={generate}>Загрузить из UI</Button>
<Button variant="default" disabled={demo || busy || !state?.desired_revision} onClick={() => replace(() => loadRevision(String(state?.desired_revision)))}>Загрузить назначенную</Button>
<Button variant="default" disabled={demo || busy} onClick={resume}>Вернуться к сборке из UI</Button>
</Group>
<Select label="История конфигураций" placeholder="Загрузить версию" value={revision} data={(query.data?.revisions ?? []).map((r) => ({value: String(r.revision), label: `v${r.revision}${r.note ? ` · ${r.note}` : ''}`}))} disabled={demo || busy} onChange={(v) => { if (v) void replace(() => loadRevision(v)); }} />
<Textarea label="haproxy.cfg" disabled={busy} description={dirty ? 'Есть несохранённые изменения' : 'Сохранённый текст'} value={config} onChange={(e) => {setConfig(e.currentTarget.value);}} autosize minRows={18} maxRows={30} spellCheck={false} styles={{input: {fontFamily: 'monospace', fontSize: 13, tabSize: 4}}} />
<TextInput label="Комментарий к версии" maxLength={500} value={note} onChange={(e) => setNote(e.currentTarget.value)} />
<Group justify="space-between"><Text size="xs" c="dimmed">Лимит: 512 КиБ. Проверка HAProxy выполняется агентом перед применением.</Text><Group>
<Button variant="default" disabled={demo || busy || !config.trim() || new TextEncoder().encode(config).length > 524288} onClick={saveDraft}>Сохранить версию</Button>
<Button disabled={demo || busy || dirty || !revision} onClick={assign}>Применить версию</Button>
</Group></Group>
</Stack></Tabs.Panel>
</Tabs>
</Stack>;
}
Loading