CPU {Number.isFinite(cpu) ? `${formatNumber(cpu)}%` : '—'}
@@ -124,28 +125,28 @@ const NodeRow = memo(function NodeRow({ item, previousID, nextID, reorderEnabled
↓ {formatBitrate(rx)} ↑ {formatBitrate(tx)}
-
{formatBytes(used)} {limit > 0 ? `/ ${formatBytes(limit)}` : 'без лимита'}
+
{formatBytes(used)} {limit > 0 ? `/ ${formatBytes(limit)}` : t('nodes.noLimit')}
0 ? '' : ' is-unlimited'}`}
role="img"
- aria-label={limit > 0 ? `Использовано ${Math.round(quota)} процентов квоты` : 'Трафик без лимита'}
+ aria-label={limit > 0 ? t('nodes.roundPercentOfQuotaUsed', { round: Math.round(quota) }) : t('nodes.trafficWithoutLimit')}
>
0 ? quota / 100 : 1 } as CSSProperties} />
-
{limit > 0 ? `${Math.round(quota)}%` : item.traffic_month || 'нет данных'}
+
{limit > 0 ? `${Math.round(quota)}%` : item.traffic_month || t('nodes.noData')}
HAProxy {shortHAProxy(metrics?.haproxy_version)} Agent {heartbeat?.agent_version ?? '—'}
-
+
- } onClick={open}>Открыть ноду
- } onClick={() => navigate(newRouteURL)}>Добавить маршрут
- } onClick={() => navigate(`${nodeURL}#operations`)}>Управление нодой
+ } onClick={open}>{t('nodes.openNode')}
+ } onClick={() => navigate(newRouteURL)}>{t('nodes.addRoute')}
+ } onClick={() => navigate(`${nodeURL}#operations`)}>{t('nodes.nodeManagement')}
@@ -197,9 +198,9 @@ export function NodesTable({ nodes, page, pageSize, total, onPageChange, reorder
void latest.current.onReorder(sourceID, targetID);
}, []);
return (
-
+
- Статус Нода Маршруты Соединения CPU / RAM RX / TX Трафик за месяц HAProxy / Agent
+ {t('nodes.status')} {t('nodes.node')} {t('nodes.routes')} {t('nodes.connections')} CPU / RAM RX / TX {t('nodes.monthlyTraffic')} HAProxy / Agent
{nodes.map((item, index) => (
@@ -222,9 +223,9 @@ export function NodesTable({ nodes, page, pageSize, total, onPageChange, reorder
))}
- Показано {total ? (page - 1) * pageSize + 1 : 0}–{Math.min(total, page * pageSize)} из {total} нод
+ {t('nodes.showingNodes', { from: total ? (page - 1) * pageSize + 1 : 0, to: Math.min(total, page * pageSize), count: total })}
- {pageSize} / стр.
+ {t('app.perPage', { size: pageSize })}
);
diff --git a/frontend/src/features/nodes/RoutesRanking.tsx b/frontend/src/features/nodes/RoutesRanking.tsx
index dfb0754..006801e 100644
--- a/frontend/src/features/nodes/RoutesRanking.tsx
+++ b/frontend/src/features/nodes/RoutesRanking.tsx
@@ -3,11 +3,12 @@ import { Link, useLocation } from 'react-router-dom';
import type { DashboardRoute } from '../../lib/contracts';
import { formatBitrate, formatBytes } from '../../lib/format';
import { demoSuffix } from '../../lib/navigation';
+import { t } from '../../i18n';
function routeLabel(route: DashboardRoute): string {
if (route.name) return route.name;
- if (route.fallback) return `Весь трафик · ${route.listener_ip || '*'}:${route.listener_port}`;
- return route.snis[0] ?? `Маршрут · ${route.listener_ip || '*'}:${route.listener_port}`;
+ if (route.fallback) return t('nodes.allTrafficP0ListenerPort', { p0: route.listener_ip || '*', listener_port: route.listener_port });
+ return route.snis[0] ?? t('nodes.routeP0ListenerPort', { p0: route.listener_ip || '*', listener_port: route.listener_port });
}
export function RoutesRanking({ routes }: { routes: DashboardRoute[] }) {
@@ -16,10 +17,10 @@ export function RoutesRanking({ routes }: { routes: DashboardRoute[] }) {
return (
- Топ маршрутов
+ {t('nodes.topRoutes')}
RX + TX
-
# Маршрут Нода Трафик %
+
# {t('nodes.route')} {t('nodes.node')} {t('app.traffic')} %
{visible.length ? visible.map((route, index) => (
@@ -29,12 +30,12 @@ export function RoutesRanking({ routes }: { routes: DashboardRoute[] }) {
{route.node_name}
-
{formatBitrate(route.bits_per_second)}
+
{formatBitrate(route.bits_per_second)}
{Math.round(route.share_percent)}%
- )) :
Маршрутов пока нет }
+ )) :
{t('nodes.noRoutesYet')} }
-
Посмотреть все маршруты
+
{t('nodes.viewAllRoutes')}
);
}
diff --git a/frontend/src/features/nodes/TrafficChart.tsx b/frontend/src/features/nodes/TrafficChart.tsx
index c8fa925..00ef06d 100644
--- a/frontend/src/features/nodes/TrafficChart.tsx
+++ b/frontend/src/features/nodes/TrafficChart.tsx
@@ -10,6 +10,7 @@ import { nodeFlowChartTheme } from '../../lib/appearance';
import type { TrafficHistorySample } from '../../lib/contracts';
import { formatBitrate } from '../../lib/format';
import type { TrafficRange } from './useNodesOverview';
+import { t, locale } from '../../i18n';
interface TrafficChartProps {
samples: TrafficHistorySample[];
@@ -27,12 +28,12 @@ interface TrafficChartProps {
}
const ranges = [
- { label: '1м', value: '1m' },
- { label: '5м', value: '5m' },
- { label: '1ч', value: '1h' },
- { label: '24ч', value: '24h' },
- { label: '7д', value: '7d' },
- { label: '30д', value: '30d' },
+ { label: t('nodes.n1m'), value: '1m' },
+ { label: t('nodes.n5m'), value: '5m' },
+ { label: t('nodes.n1h'), value: '1h' },
+ { label: t('nodes.n24h'), value: '24h' },
+ { label: t('nodes.n7d'), value: '7d' },
+ { label: t('nodes.n30d'), value: '30d' },
];
type TooltipParam = { axisValueLabel?: string; marker?: string; seriesName?: string; value?: [number, number | null] };
@@ -40,18 +41,18 @@ type TooltipParam = { axisValueLabel?: string; marker?: string; seriesName?: str
function formatAxisTick(value: number | string, range: TrafficRange) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
- if (range === '1m') return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(date);
- if (range === '5m' || range === '1h' || range === '24h') return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(date);
- return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' }).format(date);
+ if (range === '1m') return new Intl.DateTimeFormat(locale(), { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(date);
+ if (range === '5m' || range === '1h' || range === '24h') return new Intl.DateTimeFormat(locale(), { hour: '2-digit', minute: '2-digit' }).format(date);
+ return new Intl.DateTimeFormat(locale(), { day: '2-digit', month: '2-digit' }).format(date);
}
echarts.use([LineChart, GridComponent, TooltipComponent, AriaComponent, CanvasRenderer]);
export function TrafficChart({
samples, range, onRangeChange, currentRx, currentTx, status,
- emptyTitle = 'История трафика собирается',
- emptyDescription = 'Первая точка появится после сигнала Node Agent.',
- emptyFooterLabel = 'Ожидаем первую точку',
+ emptyTitle = t('nodes.collectingTrafficHistory'),
+ emptyDescription = t('nodes.theFirstSampleWillAppear'),
+ emptyFooterLabel = t('nodes.waitingForTheFirstSample'),
}: TrafficChartProps) {
const reducedMotion = useReducedMotion();
const compactChart = useMediaQuery('(max-width: 479px)') ?? false;
@@ -153,22 +154,22 @@ export function TrafficChart({
}), [samples, range, reducedMotion, compactChart, timeExtent, chartTheme]);
const updatedAt = latest?.timestamp ? new Date(latest.timestamp) : null;
const updatedLabel = updatedAt && !Number.isNaN(updatedAt.getTime())
- ? `Обновлено: ${new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(updatedAt)}`
+ ? t('nodes.updatedP0', { p0: new Intl.DateTimeFormat(locale(), { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(updatedAt) })
: emptyFooterLabel;
const explicitCurrent = currentRx !== undefined || currentTx !== undefined;
const actualRx = currentRx === undefined ? latest?.rx_bps : currentRx;
const actualTx = currentTx === undefined ? latest?.tx_bps : currentTx;
- const valueScope = explicitCurrent ? 'Сейчас' : 'Последняя точка';
- const chartLabel = `Трафик за период ${ranges.find((item) => item.value === range)?.label ?? range}. ${valueScope} RX ${formatBitrate(actualRx)}, TX ${formatBitrate(actualTx)}.`;
+ const valueScope = explicitCurrent ? t('nodes.now') : t('nodes.latestSample');
+ const chartLabel = t('nodes.trafficForP0ValuescopeRx', { p0: ranges.find((item) => item.value === range)?.label ?? range, valueScope, formatBitrate: formatBitrate(actualRx), p3: formatBitrate(actualTx) });
const currentStatus = status ?? {
tone: latest ? 'stale' as const : 'offline' as const,
- label: latest ? 'Исторические данные' : 'Нет свежих данных',
+ label: latest ? t('nodes.historicalData') : t('nodes.noFreshData'),
};
return (
-
+
RX {formatBitrate(actualRx)}
TX {formatBitrate(actualTx)}
@@ -178,7 +179,7 @@ export function TrafficChart({
onChange={(value) => onRangeChange(value as TrafficRange)}
data={ranges}
size="sm"
- aria-label="Период графика"
+ aria-label={t('nodes.chartPeriod')}
/>
{latest ? (
diff --git a/frontend/src/features/nodes/model.ts b/frontend/src/features/nodes/model.ts
index 47a6365..7eea4c6 100644
--- a/frontend/src/features/nodes/model.ts
+++ b/frontend/src/features/nodes/model.ts
@@ -1,5 +1,6 @@
import type { DashboardNode, HeartbeatMetrics, NodeBundle, RouteRecord, TrafficHistorySample } from '../../lib/contracts';
import type { TrafficRange } from './useNodesOverview';
+import { t } from '../../i18n';
export type OperationalState = 'online' | 'degraded' | 'offline';
@@ -43,6 +44,6 @@ export function filterSamples(samples: TrafficHistorySample[], range: TrafficRan
export function routeName(route: RouteRecord): string {
return route.name || (route.fallback
- ? `Весь трафик · ${route.listener_ip || '*'}:${route.listener_port}`
- : route.snis[0] ?? route.hostname ?? 'Без имени');
+ ? t('nodes.allTrafficP0ListenerPort', { p0: route.listener_ip || '*', listener_port: route.listener_port })
+ : route.snis[0] ?? route.hostname ?? t('nodes.unnamed'));
}
diff --git a/frontend/src/features/routes/RouteLibraryDialog.tsx b/frontend/src/features/routes/RouteLibraryDialog.tsx
index 47163d6..a1efbe8 100644
--- a/frontend/src/features/routes/RouteLibraryDialog.tsx
+++ b/frontend/src/features/routes/RouteLibraryDialog.tsx
@@ -5,11 +5,12 @@ 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';
+import { apiErrorText, t } from '../../i18n';
// 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-сокеты перед включением.';
+const COPY_NOTE = t('templates.copiesAreCreatedDisabledCounters');
+const LISTENER_HINT = t('templates.onAnotherNodeCheckThe');
function errorText(e: unknown, fallback: string) {
return e instanceof Error ? e.message : fallback;
@@ -30,17 +31,17 @@ function CopyTargets({ nodes, nodeID, targets, setTargets, name, setName, namePl
const nodeName = (id: string) => nodes.find((n) => n.id === id)?.name || id;
return <>
({ value: n.id, label: n.id === nodeID ? `${n.name} (эта нода)` : n.name }))}
+ label={t('templates.targetNodes')} placeholder={targets.length ? '' : t('templates.selectNodes')} searchable maxValues={100}
+ data={nodes.map((n) => ({ value: n.id, label: n.id === nodeID ? t('templates.nameThisNode', { name: 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 &&
+ setName(e.currentTarget.value)} maxLength={80} disabled={busy} />
+ {results.length > 0 &&
{results.map((r) =>
{nodeName(r.node_id)}
- {r.error || 'Черновик создан'}
- {r.route && onOpen(r)}>Открыть }
+ {r.error ? apiErrorText(r.error_code, r.error) : t('templates.draftCreated')}
+ {r.route && onOpen(r)}>{t('templates.open')} }
)}
}
>;
@@ -63,7 +64,7 @@ export function RouteCopyDialog({ opened, onClose, nodeID, source }: {
if (!opened) return;
let active = true;
setError(''); setResults([]); setTargets([]); setName('');
- templateNodes().then((n) => { if (active) setNodes(n); }).catch((e) => { if (active) setError(errorText(e, 'Список нод недоступен')); });
+ templateNodes().then((n) => { if (active) setNodes(n); }).catch((e) => { if (active) setError(errorText(e, t('templates.nodeListUnavailable'))); });
return () => { active = false; };
}, [opened, source?.id]);
const copy = async () => {
@@ -76,10 +77,10 @@ export function RouteCopyDialog({ opened, onClose, nodeID, source }: {
// 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); }
+ } catch (e) { setError(errorText(e, t('templates.copiesNotCreated'))); } finally { setBusy(false); }
};
const done = results.length > 0 && targets.length === 0;
- return {} : onClose} title="Копировать на ноды" size="md" closeOnClickOutside={!busy} closeOnEscape={!busy} classNames={dialogClassNames}>
+ return {} : onClose} title={t('node.copyToNodes')} size="md" closeOnClickOutside={!busy} closeOnEscape={!busy} classNames={dialogClassNames}>
{COPY_NOTE}
{error && {error}
}
- {done ? 'Закрыть' : 'Отмена'}
- {!done && } loading={busy} disabled={!targets.length} onClick={() => void copy()}>Копировать}
+ {done ? t('nodes.close') : t('node.cancel')}
+ {!done && } loading={busy} disabled={!targets.length} onClick={() => void copy()}>{t('templates.copy')}}
;
}
/** «Сохранить как шаблон»: name and an optional short description. */
-export function RouteTemplateSaveDialog({ opened, onClose, title = 'Сохранить как шаблон', initialName, initialDescription = '', onSave }: {
+export function RouteTemplateSaveDialog({ opened, onClose, title = t('node.saveAsTemplate'), initialName, initialDescription = '', onSave }: {
opened: boolean; onClose: () => void; title?: string; initialName: string; initialDescription?: string;
onSave: (name: string, description: string) => Promise;
}) {
@@ -111,17 +112,17 @@ export function RouteTemplateSaveDialog({ opened, onClose, title = 'Сохран
}, [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); }
+ try { await onSave(name.trim(), description.trim()); } catch (e) { setError(errorText(e, t('templates.templateNotSaved'))); } 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 />
-
;
@@ -135,10 +136,10 @@ export function RouteSaveTemplateDialog({ opened, onClose, source }: { opened: b
/>;
}
-function templateSummary(t: RouteTemplate): string {
- const c = t.config;
+function templateSummary(tpl: RouteTemplate): string {
+ const c = tpl.config;
const listener = `${String(c.listener_ip || '*')}:${String(c.listener_port || 443)}`;
- const snis = Array.isArray(c.snis) && c.snis.length ? c.snis.join(', ') : 'фолбэк';
+ const snis = Array.isArray(c.snis) && c.snis.length ? c.snis.join(', ') : t('templates.fallback');
return `${listener} · ${snis}`;
}
@@ -157,14 +158,14 @@ export function RouteLibraryDialog({ opened, onClose, nodeID }: { opened: boolea
const [error, setError] = useState('');
const [results, setResults] = useState([]);
const [deleting, setDeleting] = useState(null);
- const template = templates?.find((t) => t.id === selected);
+ const template = templates?.find((tpl) => tpl.id === selected);
useEffect(() => {
if (!opened) return;
let active = true;
setError(''); setResults([]); setSelected(null); setTargets([nodeID]); setName(''); setTemplates(null);
Promise.all([templateNodes(), listTemplates()])
- .then(([n, t]) => { if (active) { setNodes(n); setTemplates(t); } })
- .catch((e) => { if (active) { setError(errorText(e, 'Шаблоны недоступны')); setTemplates([]); } });
+ .then(([n, list]) => { if (active) { setNodes(n); setTemplates(list); } })
+ .catch((e) => { if (active) { setError(errorText(e, t('templates.templatesUnavailable'))); setTemplates([]); } });
return () => { active = false; };
}, [opened, nodeID]);
const go = (path: string) => { onClose(); navigate(path); };
@@ -176,7 +177,7 @@ export function RouteLibraryDialog({ opened, onClose, nodeID }: { opened: boolea
setResults(copied);
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); }
+ } catch (e) { setError(errorText(e, t('templates.routesNotCreated'))); } finally { setBusy(false); }
};
const remove = async () => {
if (!deleting) return;
@@ -186,29 +187,29 @@ export function RouteLibraryDialog({ opened, onClose, nodeID }: { opened: boolea
if (selected === deleting.id) setSelected(null);
setTemplates(await listTemplates());
setDeleting(null);
- } catch (e) { setError(errorText(e, 'Шаблон не удалён')); setDeleting(null); } finally { setBusy(false); }
+ } catch (e) { setError(errorText(e, t('templates.templateNotDeleted'))); setDeleting(null); } finally { setBusy(false); }
};
const done = results.length > 0 && targets.length === 0;
return <>
- {} : onClose} title="Шаблоны маршрутов" size="lg" closeOnClickOutside={!busy} closeOnEscape={!busy} classNames={dialogClassNames}>
+ {} : onClose} title={t('node.routeTemplates')} size="lg" closeOnClickOutside={!busy} closeOnEscape={!busy} classNames={dialogClassNames}>
{COPY_NOTE}
- {templates === null ? Загрузка… : templates.length === 0
- ? Шаблонов нет. Сохраните маршрут как шаблон из меню маршрута.
- :
- !busy && setDeleting(null)} title="Удалить шаблон?" classNames={dialogClassNames}>
+ !busy && setDeleting(null)} title={t('templates.deleteTemplate')} classNames={dialogClassNames}>
-
Шаблон {deleting?.name} будет удалён. Созданные из него маршруты не изменятся.
-
setDeleting(null)} disabled={busy}>Отмена } onClick={() => void remove()} loading={busy}>Удалить
+
{t('templates.template')} {deleting?.name} {t('templates.willBeDeletedRoutesCreated')}
+
setDeleting(null)} disabled={busy}>{t('node.cancel')} } onClick={() => void remove()} loading={busy}>{t('node.delete')}
>;
diff --git a/frontend/src/features/routes/model.ts b/frontend/src/features/routes/model.ts
index 6640be2..0d04241 100644
--- a/frontend/src/features/routes/model.ts
+++ b/frontend/src/features/routes/model.ts
@@ -3,6 +3,7 @@ import { agentVersionAtLeast } from '../../lib/agentVersion';
import type { RouteRecord } from '../../lib/contracts';
import { formatBytes } from '../../lib/format';
import { foldLeastConnCosts } from './leastconnFold';
+import { t, locale } from '../../i18n';
export type RouteMatchMode = 'sni' | 'fallback';
export type RouteTargetMode = 'ip' | 'domain' | 'unix';
@@ -217,9 +218,9 @@ export const DEFAULT_LEASTPING_TOLERANCE_PCT = 20;
/** «Как выбирать сервер»: the three distribution choices (stickyMode 1:1). */
export const distributionChoiceOptions: { value: DistributionChoice; label: string; description: string }[] = [
- { value: 'algorithm', label: 'По алгоритму', description: 'Каждое новое соединение получает сервер по алгоритму.' },
- { value: 'hash', label: 'Закреплять по IP', description: 'Сервер определяется IP клиента, алгоритм не нужен.' },
- { value: 'table', label: 'Запоминать клиента', description: 'Первый сервер выбирает алгоритм, дальше клиент остаётся на нём.' },
+ { value: 'algorithm', label: t('routeModel.byAlgorithm'), description: t('routeModel.eachNewConnectionGetsA') },
+ { value: 'hash', label: t('routeModel.pinByIp'), description: t('routeModel.theServerIsDeterminedBy') },
+ { value: 'table', label: t('routeModel.rememberClient'), description: t('routeModel.theAlgorithmPicksTheFirst') },
];
export function distributionChoice(mode: StickyMode): DistributionChoice {
@@ -262,7 +263,7 @@ export function ttlParts(value: string): { amount: number | ''; unit: TTLUnit }
return { amount: n, unit: match[2] as TTLUnit };
}
-export const ttlUnitLabels: Record = { s: 'сек', m: 'мин', h: 'ч' };
+export const ttlUnitLabels: Record = { s: t('routeModel.s'), m: t('routeModel.min'), h: t('routeModel.h') };
export function ttlQuickPickLabel(value: string): string {
const { amount, unit } = ttlParts(value);
@@ -273,7 +274,7 @@ export type TableUnit = 'k' | 'm';
/** HAProxy size suffixes: k = 1024, m = 1024². */
const TABLE_UNIT_ENTRIES: Record = { k: 1024, m: 1024 * 1024 };
export const TABLE_MAX: Record = { k: 10000, m: 10 };
-export const tableUnitLabels: Record = { k: 'тыс.', m: 'млн' };
+export const tableUnitLabels: Record = { k: t('routeModel.k'), m: t('routeModel.m') };
/**
* Measured HAProxy 3.4.2 RSS per stick-table entry with server_id (≈226-228 B;
* `type ip` measured the same as `type ipv6`). Allocated as the table fills.
@@ -350,17 +351,17 @@ export function tableEntriesToken(count: number | ''): string {
return `${Math.ceil(clamped / TABLE_UNIT_ENTRIES.m)}m`;
}
-/** «102 400» — Russian thousands grouping. */
+/** «102 400» / «102,400» — thousands grouping in the UI locale. */
export function formatCount(value: number): string {
- return value.toLocaleString('ru-RU').replace(/\u00a0/g, ' ');
+ return value.toLocaleString(locale()).replace(/\u00a0/g, ' ');
}
/** «Авто» hint: «Авто: ~941 000 клиентов (5% RAM ноды), ≈205 МБ при полной таблице». */
export function autoTableHint(effective: string, memoryKnown: boolean): string {
// Unknown RAM renders 1m (1 048 576 entries).
- if (!memoryKnown) return 'Авто: 1 000 000, RAM ноды неизвестна';
+ if (!memoryKnown) return t('routeModel.auto1000000Node');
const clients = Math.round(tableEntriesCount(effective) / 1000) * 1000;
- return `Авто: ~${formatCount(clients)} клиентов (5% RAM ноды), ${tableMemoryEstimate(effective)} при полной таблице`;
+ return t('routeModel.autoFormatcountClients5Of', { formatCount: formatCount(clients), tableMemoryEstimate: tableMemoryEstimate(effective) });
}
/** Approximate memory at full fill, e.g. «≈22 МБ». */
@@ -368,17 +369,17 @@ export function tableMemoryEstimate(value: string): string {
const entries = tableEntriesCount(value);
if (entries === 0) return '';
const mib = (entries * STICK_TABLE_ENTRY_BYTES) / (1024 * 1024);
- if (mib >= 1024) return `≈${(mib / 1024).toFixed(1).replace('.', ',')} ГБ`;
- if (mib >= 10) return `≈${Math.round(mib)} МБ`;
- return `≈${mib.toFixed(1).replace('.', ',')} МБ`;
+ if (mib >= 1024) return t('routeModel.p0Gb', { p0: (mib / 1024).toFixed(1).replace('.', ',') });
+ if (mib >= 10) return t('routeModel.roundMb', { round: Math.round(mib) });
+ return t('routeModel.p0Mb', { p0: mib.toFixed(1).replace('.', ',') });
}
/** «Алгоритм» choices (static-rr is roundrobin with «Веса меняются на лету» off). */
export const balanceAlgorithmOptions: { value: BalanceAlgorithm; label: string; description: string }[] = [
- { value: 'roundrobin', label: 'По кругу', description: 'Серверы получают клиентов по очереди, с учётом веса.' },
- { value: 'random', label: 'Случайно', description: 'Случайный сервер с учётом веса.' },
- { value: 'leastconn', label: 'Меньше соединений', description: 'Сервер, где сейчас меньше всего соединений.' },
- { value: 'leastping', label: 'Меньше задержка', description: 'Больше трафика серверам с меньшей задержкой (как leastLoad в Xray).' },
+ { value: 'roundrobin', label: t('routeModel.roundRobin'), description: t('routeModel.serversGetClientsInTurn') },
+ { value: 'random', label: t('routeModel.random'), description: t('routeModel.aRandomServerWeighted') },
+ { value: 'leastconn', label: t('routeModel.fewestConnections'), description: t('routeModel.theServerWithTheFewest') },
+ { value: 'leastping', label: t('routeModel.lowestLatency'), description: t('routeModel.moreTrafficToServersWith') },
];
/** Top-level select family for a stored algorithm (static-rr collapses into roundrobin). */
@@ -425,10 +426,10 @@ function loadedBalanceAlgorithm(route: RouteRecord): BalanceAlgorithm | null {
}
export const quotaPeriodOptions = [
- { value: 'hourly', label: 'Каждый час', description: 'Сбрасывается в начале следующего часа.' },
- { value: 'daily', label: 'Ежедневно', description: 'Сбрасывается в 00:00 UTC.' },
- { value: 'calendar_month', label: 'Календарный месяц', description: 'Сбрасывается первого числа в 00:00 UTC.' },
- { value: 'monthly_from_creation', label: 'Месяц от создания', description: 'Окно начинается в дату создания маршрута.' },
+ { value: 'hourly', label: t('routeModel.hourly'), description: t('routeModel.resetsAtTheStartOf') },
+ { value: 'daily', label: t('routeModel.daily'), description: t('routeModel.resetsAt0000Utc') },
+ { value: 'calendar_month', label: t('routeModel.calendarMonth'), description: t('routeModel.resetsOnThe1stAt') },
+ { value: 'monthly_from_creation', label: t('routeModel.monthFromCreation'), description: t('routeModel.theWindowStartsOnThe') },
] as const;
export function routeDisplayName(route: RouteRecord): string {
@@ -838,17 +839,17 @@ export function quotaBytes(draft: RouteDraft): number | null {
export function validateExpertOverride(value: string): RouteDraftError[] {
const encoder = new TextEncoder();
if (encoder.encode(value).length > 8192) {
- return [{ field: 'expert', message: 'Экспертный слой превышает 8192 байт.' }];
+ return [{ field: 'expert', message: t('routeModel.expertLayerExceeds8192Bytes') }];
}
if (!isWellFormedUTF16(value)) {
- return [{ field: 'expert', message: 'Экспертный слой должен быть корректным UTF-8.' }];
+ return [{ field: 'expert', message: t('routeModel.expertLayerMustBeValid') }];
}
const normalizedInput = value.replace(/\r\n/g, '\n');
if (/[\u0000-\u0008\u000b-\u001f\u007f]/.test(normalizedInput)) {
- return [{ field: 'expert', message: 'Экспертный слой содержит управляющий символ.' }];
+ return [{ field: 'expert', message: t('routeModel.expertLayerContainsAControl') }];
}
if (normalizedInput.includes('\\')) {
- return [{ field: 'expert', message: 'Перенос и экранирование директив через \\ запрещены.' }];
+ return [{ field: 'expert', message: t('routeModel.lineContinuationAndEscapingDirectives') }];
}
const normalizedLines: string[] = [];
@@ -857,7 +858,7 @@ export function validateExpertOverride(value: string): RouteDraftError[] {
const line = rawLine.trim();
// Backend counts the directive without indentation (stored fragments are indented).
if (encoder.encode(line).length > 512) {
- return [{ field: 'expert', message: 'Одна из директив длиннее 512 байт.' }];
+ return [{ field: 'expert', message: t('routeModel.oneOfTheDirectivesIs') }];
}
if (!line) {
if (normalizedLines.length > 0) blankPending = true;
@@ -865,7 +866,7 @@ export function validateExpertOverride(value: string): RouteDraftError[] {
}
const directive = line.split(/\s+/)[0].toLowerCase();
if (FORBIDDEN_SECTIONS.has(directive)) {
- return [{ field: 'expert', message: `Секция ${directive} управляется NodeFlow и не может быть переопределена.` }];
+ return [{ field: 'expert', message: t('routeModel.theDirectiveSectionIsManaged', { directive }) }];
}
if (blankPending) {
normalizedLines.push('');
@@ -874,7 +875,7 @@ export function validateExpertOverride(value: string): RouteDraftError[] {
normalizedLines.push(` ${line}`);
}
if (encoder.encode(normalizedLines.join('\n')).length > 8192) {
- return [{ field: 'expert', message: 'Экспертный слой превышает 8192 байт после нормализации.' }];
+ return [{ field: 'expert', message: t('routeModel.expertLayerExceeds8192Bytes2') }];
}
return [];
}
@@ -889,29 +890,29 @@ export function validateDistribution(draft: RouteDraft): RouteDraftError[] {
const sticky = effectiveStickyMode(draft);
const algorithm = effectiveBalanceAlgorithm(draft);
if (effectiveBalanceMode(draft) === 'failover' && sticky !== 'source') {
- if (algorithm === 'leastping') add('balanceAlgorithm', 'С резервом выберите алгоритм HAProxy, например «Меньше соединений».');
- if (algorithm === 'leastconn' && Number(draft.leastpingTolerancePct) > 0) add('leastpingTolerancePct', 'С резервом толерантность должна быть 0 %.');
+ if (algorithm === 'leastping') add('balanceAlgorithm', t('routeModel.withBackupsChooseAnHaproxy'));
+ if (algorithm === 'leastconn' && Number(draft.leastpingTolerancePct) > 0) add('leastpingTolerancePct', t('routeModel.withBackupsToleranceMustBe'));
}
if (sticky !== 'source') {
- if (algorithm === 'random' && !inRange(draft.balanceRandomDraws, 1, 5)) add('balanceRandomDraws', 'Выборок — от 1 до 5.');
+ if (algorithm === 'random' && !inRange(draft.balanceRandomDraws, 1, 5)) add('balanceRandomDraws', t('routeModel.drawsMustBeFrom1'));
if (algorithm === 'leastping') {
- if (!inRange(draft.leastpingTolerancePct, 0, 100)) add('leastpingTolerancePct', 'Толерантность — целое число от 0 до 100 %.');
- if (!inRange(draft.leastpingToleranceMs, 0, 1000)) add('leastpingToleranceMs', 'Толерантность в мс — от 0 до 1000.');
+ if (!inRange(draft.leastpingTolerancePct, 0, 100)) add('leastpingTolerancePct', t('routeModel.toleranceMustBeAnInteger'));
+ if (!inRange(draft.leastpingToleranceMs, 0, 1000)) add('leastpingToleranceMs', t('routeModel.toleranceInMsMustBe'));
}
if (algorithm === 'leastconn' && !inRange(draft.leastpingTolerancePct, 0, 100)) {
- add('leastpingTolerancePct', 'Толерантность — целое число от 0 до 100 %.');
+ add('leastpingTolerancePct', t('routeModel.toleranceMustBeAnInteger'));
}
}
if (sticky === 'source' && draft.stickyHash !== 'map-based' && !inRange(draft.stickyHashBalanceFactor, 101, 1000)) {
- add('stickyHashBalanceFactor', 'Лимит перегрузки — от 101 до 1000 % или пусто.');
+ add('stickyHashBalanceFactor', t('routeModel.overloadLimitMustBeFrom'));
}
if (draft.clientIPv6 && (sticky === 'source' || sticky === 'source_table')) {
- if (draft.stickyIPv6Prefix !== 0 && !inRange(draft.stickyIPv6Prefix, 32, 128)) add('stickyIPv6Prefix', 'Префикс IPv6 — от /32 до /128.');
+ if (draft.stickyIPv6Prefix !== 0 && !inRange(draft.stickyIPv6Prefix, 32, 128)) add('stickyIPv6Prefix', t('routeModel.ipv6PrefixMustBeFrom'));
}
if (sticky === 'source_table') {
const ttl = haproxyTimeSeconds(draft.stickyTTL || DEFAULT_STICKY_TTL);
- if (ttl === null || ttl < STICKY_TTL_MIN_SECONDS || ttl > STICKY_TTL_MAX_SECONDS) add('stickyTTL', 'Помнить — от 1 минуты до 168 часов.');
- if (!validTableEntries(draft.stickyTableEntries)) add('stickyTableEntries', `Макс. клиентов — от 1 до ${formatCount(TABLE_MAX_CLIENTS)}.`);
+ if (ttl === null || ttl < STICKY_TTL_MIN_SECONDS || ttl > STICKY_TTL_MAX_SECONDS) add('stickyTTL', t('routeModel.rememberForMustBeFrom'));
+ if (!validTableEntries(draft.stickyTableEntries)) add('stickyTableEntries', t('routeModel.maxClientsMustBeFrom', { formatCount: formatCount(TABLE_MAX_CLIENTS) }));
}
return errors;
}
@@ -924,93 +925,93 @@ export function validateRouteDraft(draft: RouteDraft, peers: RouteRecord[], edit
const isWildcard = listenerIP === '*' || listenerIP === '0.0.0.0' || listenerIP === '::';
const snis = draft.snis.map((value) => value.trim().replace(/\.$/, '').toLowerCase()).filter(Boolean);
- if (!draft.name.trim()) add('name', 'Укажите имя маршрута. Оно видно только оператору.');
- else if (draft.name.trim().length > 80) add('name', 'Имя маршрута не должно превышать 80 символов.');
+ if (!draft.name.trim()) add('name', t('routeModel.enterARouteNameIt'));
+ else if (draft.name.trim().length > 80) add('name', t('routeModel.routeNameMustNotExceed'));
// Backend containsForbiddenControl: "route fields cannot contain control characters".
- if (/[\u0000-\u001f\u007f]/.test(draft.name)) add('name', 'Имя маршрута не должно содержать табуляцию и управляющие символы.');
- if (!isWildcard && !isIPAddress(listenerIP)) add('listenerIP', 'Адрес listener должен быть * или корректным IP.');
- if (!Number.isInteger(listenerPort) || listenerPort < 1 || listenerPort > 65535) add('listenerPort', 'Порт listener должен быть от 1 до 65535.');
+ if (/[\u0000-\u001f\u007f]/.test(draft.name)) add('name', t('routeModel.routeNameMustNotContain'));
+ if (!isWildcard && !isIPAddress(listenerIP)) add('listenerIP', t('routeModel.listenerAddressMustBeOr'));
+ if (!Number.isInteger(listenerPort) || listenerPort < 1 || listenerPort > 65535) add('listenerPort', t('routeModel.listenerPortMustBeFrom'));
if (draft.matchMode === 'sni') {
- if (!snis.length) add('snis', 'Добавьте хотя бы один SNI.');
- if (snis.length > 64) add('snis', 'Можно указать не больше 64 SNI.');
- if (snis.some((sni) => !isDNSName(sni))) add('snis', 'Каждый SNI должен быть корректным DNS-именем.');
- if (new Set(snis).size !== snis.length) add('snis', 'SNI внутри маршрута не должны повторяться.');
+ if (!snis.length) add('snis', t('routeModel.addAtLeastOneSni'));
+ if (snis.length > 64) add('snis', t('routeModel.noMoreThan64Snis'));
+ if (snis.some((sni) => !isDNSName(sni))) add('snis', t('routeModel.eachSniMustBeA'));
+ if (new Set(snis).size !== snis.length) add('snis', t('routeModel.snisWithinARouteMust'));
}
// accept_proxy_from validation
// An empty list with the toggle on means "expect PROXY from everyone".
if (draft.acceptProxyEnabled) {
if (draft.acceptProxyFrom.length > 256) {
- add('acceptProxyFrom', 'Список не должен превышать 256 записей.');
+ add('acceptProxyFrom', t('routeModel.theListMustNotExceed'));
} else {
for (const entry of draft.acceptProxyFrom) {
if (!isValidTrustedProxyEntry(entry)) {
- add('acceptProxyFrom', `Некорректный адрес, подсеть или домен: ${entry}`);
+ add('acceptProxyFrom', t('routeModel.invalidAddressSubnetOrDomain', { entry }));
break;
}
}
const domains = new Set(draft.acceptProxyFrom.filter(isTrustedProxyHostname).map(normalizeTrustedProxyEntry));
if (domains.size > MAX_ACCEPT_PROXY_DOMAINS) {
- add('acceptProxyFrom', `Не больше ${MAX_ACCEPT_PROXY_DOMAINS} доменов в списке.`);
+ add('acceptProxyFrom', t('routeModel.noMoreThanMaxAccept', { MAX_ACCEPT_PROXY_DOMAINS }));
}
}
}
// Server validation
if (draft.servers.length === 0) {
- add('servers', 'Добавьте хотя бы один сервер назначения.');
+ add('servers', t('routeModel.addAtLeastOneDestination'));
} else if (draft.servers.length > 16) {
- add('servers', 'Не более 16 серверов.');
+ add('servers', t('routeModel.noMoreThan16Servers'));
} else {
const names = draft.servers.map((s) => s.name.trim()).filter(Boolean);
const reserved = draft.servers.find((s) => /_pref$/i.test(s.name.trim()));
- if (reserved) add('servers', `Имя сервера ${reserved.name.trim()} не должно оканчиваться на _pref.`);
- if (new Set(names).size !== names.length) add('servers', 'Имена серверов не должны повторяться.');
+ if (reserved) add('servers', t('routeModel.serverNameTrimMustNot', { trim: reserved.name.trim() }));
+ if (new Set(names).size !== names.length) add('servers', t('routeModel.serverNamesMustNotRepeat'));
draft.servers.forEach((s, i) => {
- if (!s.name.trim()) { add('servers', `Сервер ${i + 1}: укажите имя.`); return; }
- if (!SERVER_NAME_RE.test(s.name.trim())) add('servers', `Сервер ${i + 1}: имя — буквы, цифры, _, - (до ${SERVER_NAME_MAX} символов).`);
+ if (!s.name.trim()) { add('servers', t('routeModel.serverP0EnterAName', { p0: i + 1 })); return; }
+ if (!SERVER_NAME_RE.test(s.name.trim())) add('servers', t('routeModel.serverP0NameLettersDigits', { p0: i + 1, SERVER_NAME_MAX }));
if (s.targetType === 'tcp') {
const sPort = Number(s.port);
const host = s.host.trim();
- if (!host) add('servers', `Сервер ${i + 1}: укажите адрес.`);
- else if (looksLikeNumericAddress(host) && !isIPAddress(host)) add('servers', `Сервер ${i + 1}: ${host} — некорректный IPv4-адрес.`);
- else if (!isValidTargetHost(host)) add('servers', `Сервер ${i + 1}: адрес должен быть IP или доменом.`);
- if (!Number.isInteger(sPort) || sPort < 1 || sPort > 65535) add('servers', `Сервер ${i + 1}: некорректный порт.`);
- if (s.dnsPool && isIPAddress(s.host.trim())) add('servers', `Сервер ${i + 1}: DNS-пул недоступен для IP-адресов.`);
- if (preferredIPApplies(draft, i) && s.preferredIP.trim() && !isIPAddress(s.preferredIP.trim())) add('servers', `Сервер ${i + 1}: основной IP — некорректный адрес.`);
+ if (!host) add('servers', t('routeModel.serverP0EnterAnAddress', { p0: i + 1 }));
+ else if (looksLikeNumericAddress(host) && !isIPAddress(host)) add('servers', t('routeModel.serverP0HostIsNot', { p0: i + 1, host }));
+ else if (!isValidTargetHost(host)) add('servers', t('routeModel.serverP0AddressMustBe', { p0: i + 1 }));
+ if (!Number.isInteger(sPort) || sPort < 1 || sPort > 65535) add('servers', t('routeModel.serverP0InvalidPort', { p0: i + 1 }));
+ if (s.dnsPool && isIPAddress(s.host.trim())) add('servers', t('routeModel.serverP0DnsPoolIs', { p0: i + 1 }));
+ if (preferredIPApplies(draft, i) && s.preferredIP.trim() && !isIPAddress(s.preferredIP.trim())) add('servers', t('routeModel.serverP0PreferredIpIs', { p0: i + 1 }));
} else {
- if (!isCanonicalUnixSocketPath(s.unixSocketPath.trim())) add('servers', `Сервер ${i + 1}: некорректный путь Unix socket.`);
+ if (!isCanonicalUnixSocketPath(s.unixSocketPath.trim())) add('servers', t('routeModel.serverP0InvalidUnixSocket', { p0: i + 1 }));
}
if (s.weight !== '' && (!Number.isInteger(Number(s.weight)) || Number(s.weight) < 1 || Number(s.weight) > 256)) {
- add('servers', `Сервер ${i + 1}: вес — целое число от 1 до 256.`);
+ add('servers', t('routeModel.serverP0WeightMustBe', { p0: i + 1 }));
}
if (costActive(draft) && s.cost !== '' && (!Number.isFinite(Number(s.cost)) || Number(s.cost) <= 0 || Number(s.cost) > 100)) {
- add('servers', `Сервер ${i + 1}: стоимость — число больше 0 и не больше 100.`);
+ add('servers', t('routeModel.serverP0CostMustBe', { p0: i + 1 }));
}
if (s.ipWeights.length > 0) {
const algorithm = effectiveBalanceAlgorithm(draft);
const sticky = effectiveStickyMode(draft);
- if (!s.dnsPool) add('servers', `Сервер ${i + 1}: вес и стоимость IP доступны только для DNS-пула.`);
- if (s.ipWeights.length > 32) add('servers', `Сервер ${i + 1}: не более 32 отдельных IP.`);
+ if (!s.dnsPool) add('servers', t('routeModel.serverP0PerIpWeight', { p0: i + 1 }));
+ if (s.ipWeights.length > 32) add('servers', t('routeModel.serverP0NoMoreThan', { p0: i + 1 }));
const ips = s.ipWeights.map((w) => canonicalIP(w.ip).toLowerCase());
- if (new Set(ips).size !== ips.length) add('servers', `Сервер ${i + 1}: IP не должны повторяться.`);
- if (s.ipWeights.some((w) => !isIPAddress(w.ip))) add('servers', `Сервер ${i + 1}: некорректный IP в списке отдельных IP.`);
+ if (new Set(ips).size !== ips.length) add('servers', t('routeModel.serverP0IpsMustNot', { p0: i + 1 }));
+ if (s.ipWeights.some((w) => !isIPAddress(w.ip))) add('servers', t('routeModel.serverP0InvalidIpIn', { p0: i + 1 }));
if (s.ipWeights.some((w) => w.weight !== '' && (!Number.isInteger(w.weight) || w.weight < 1 || w.weight > 256))) {
- add('servers', `Сервер ${i + 1}: вес IP — целое число от 1 до 256.`);
+ add('servers', t('routeModel.serverP0PerIpWeight2', { p0: i + 1 }));
}
if (costActive(draft) && s.ipWeights.some((w) => w.cost !== '' && (!Number.isFinite(w.cost) || w.cost < 0.01 || w.cost > 100))) {
- add('servers', `Сервер ${i + 1}: стоимость IP — число от 0,01 до 100.`);
+ add('servers', t('routeModel.serverP0PerIpCost', { p0: i + 1 }));
}
const withWeight = s.ipWeights.some((w) => w.weight !== '');
if (withWeight && (algorithm === 'static-rr' || (sticky === 'source' && draft.stickyHash === 'map-based'))) {
- add('servers', `Сервер ${i + 1}: вес IP не работает с фиксированными весами — включите «Веса меняются на лету» или «Стабильное» распределение.`);
+ add('servers', t('routeModel.serverP0PerIpWeight3', { p0: i + 1 }));
}
}
});
if (effectiveBalanceMode(draft) === 'failover' && draft.servers.length > 1) {
- if (draft.servers.every((s) => s.backup)) add('servers', 'Отметьте хотя бы один сервер как «Основной».');
- else if (!draft.servers.some((s) => s.backup)) add('servers', 'Отметьте хотя бы один сервер как «Резерв» или выберите режим «Пул».');
+ if (draft.servers.every((s) => s.backup)) add('servers', t('routeModel.markAtLeastOneServer'));
+ else if (!draft.servers.some((s) => s.backup)) add('servers', t('routeModel.markAtLeastOneServer2'));
else {
// Backend resolveRouteServers: HAProxy has one backup tier, and a DNS-pool
// reserve (or the pool behind a preferred IP) needs option allbackups,
@@ -1020,8 +1021,8 @@ export function validateRouteDraft(draft: RouteDraft, peers: RouteRecord[], edit
const poolReserve = preferredPool || reserves.some((s) => s.targetType === 'tcp' && s.dnsPool);
if (poolReserve && reserves.length + (preferredPool ? 1 : 0) > 1) {
add('servers', preferredPool
- ? 'С основным IP остальные IP домена уже служат резервом — уберите основной IP или резервные серверы.'
- : 'Резервный сервер с DNS-пулом должен быть единственным резервным.');
+ ? t('routeModel.withAPreferredIpThe')
+ : t('routeModel.aBackupServerWithA'));
}
}
}
@@ -1029,55 +1030,55 @@ export function validateRouteDraft(draft: RouteDraft, peers: RouteRecord[], edit
for (const pool of draft.servers.filter((s) => s.targetType === 'tcp' && s.dnsPool && s.name.trim())) {
const prefix = `${pool.name.trim()}_`;
const clash = draft.servers.find((s) => s.name.trim().startsWith(prefix) && /^\d+$/.test(s.name.trim().slice(prefix.length)));
- if (clash) add('servers', `Имя сервера ${clash.name.trim()} совпадает с именами адресов DNS-пула ${pool.name.trim()}.`);
+ if (clash) add('servers', t('routeModel.serverNameTrimClashesWith', { trim: clash.name.trim(), p1: pool.name.trim() }));
}
}
const bytes = quotaBytes(draft);
if (draft.quotaEnabled && (draft.quotaValue === '' || Number(draft.quotaValue) <= 0 || !Number.isFinite(bytes) || Number(bytes) > MAX_SAFE_BYTES)) {
- add('quota', 'Лимит должен быть положительным числом в безопасном диапазоне.');
+ add('quota', t('routeModel.theLimitMustBeA'));
}
- if (draft.quotaAction === 'block_new' && !draft.quotaEnabled) add('quota', 'Блокировка новых соединений требует включённого лимита.');
+ if (draft.quotaAction === 'block_new' && !draft.quotaEnabled) add('quota', t('routeModel.blockingNewConnectionsRequiresThe'));
if (draft.quotaEnabled && draft.quotaAction === 'block_new' && draft.servers.length > 0) {
const shape = payloadShape(draft);
if (shape.kind === 'servers' || shape.dnsPool) {
- add('quota', 'Блокировка новых соединений доступна только для одного сервера без DNS-пула. Выберите «Только уведомить».');
+ add('quota', t('routeModel.blockingNewConnectionsIsAvailable'));
}
}
if (draft.clientBandwidthEnabled) {
for (const [field, value, label] of [
- ['clientUploadMbps', draft.clientUploadMbps, 'Лимит upload'],
- ['clientDownloadMbps', draft.clientDownloadMbps, 'Лимит download'],
+ ['clientUploadMbps', draft.clientUploadMbps, t('routeModel.uploadLimit')],
+ ['clientDownloadMbps', draft.clientDownloadMbps, t('routeModel.downloadLimit')],
] as const) {
if (value !== '' && (!Number.isSafeInteger(value) || value < 1 || value > 1_000_000)) {
- add(field, `${label} должен быть целым числом от 1 до 1 000 000 Mbps либо пустым.`);
+ add(field, t('routeModel.labelMustBeAnInteger', { label }));
}
}
}
- if (draft.slowstart && !effectiveHealthCheck(draft)) add('servers', 'Плавный ввод требует включённой проверки здоровья.');
+ if (draft.slowstart && !effectiveHealthCheck(draft)) add('servers', t('routeModel.slowStartRequiresTheHealth'));
const slowstart = draft.slowstart ? haproxyTimeSeconds(draft.slowstart) : 0;
- if (slowstart === null || slowstart > 600) add('slowstart', 'Плавный ввод — от 1 до 600 секунд.');
+ if (slowstart === null || slowstart > 600) add('slowstart', t('routeModel.slowStartMustBeFrom'));
errors.push(...validateDistribution(draft));
errors.push(...validateExpertOverride(draft.expertOverride));
// Backend dnsPoolFragmentConflict: DNS-pool templates own balancing and servers.
if (hasDNSPoolServer(draft) && draft.expertOverride.split(/\r?\n/)
.some((line) => ['balance', 'hash-type', 'server', 'server-template', 'default-server'].includes(line.trim().split(/\s+/)[0].toLowerCase()))) {
- add('expert', 'С DNS-пулом экспертный слой не может задавать balance, hash-type, server, server-template и default-server.');
+ add('expert', t('routeModel.withADnsPoolThe'));
}
for (const peer of peers.filter((route) => route.id !== editingID && !route.delete_pending && route.deployment_state !== 'deleting')) {
if (!Number.isInteger(listenerPort) || !listenerOverlaps(listenerIP, listenerPort, peer.listener_ip || '*', peer.listener_port)) continue;
const exact = sameListener(listenerIP, listenerPort, peer.listener_ip || '*', peer.listener_port);
if (!exact) {
- add('listener', `Порт ${listenerPort} уже слушается ${peer.listener_ip || '*'}. Нельзя смешивать wildcard и конкретный IP.`);
+ add('listener', t('routeModel.portListenerportIsAlreadyListened', { listenerPort, p1: peer.listener_ip || '*' }));
continue;
}
const fallback = draft.matchMode !== 'sni';
- if (fallback && peer.fallback) add('listener', `Для ${listenerIP}:${listenerPort} уже есть фолбэк-маршрут.`);
+ if (fallback && peer.fallback) add('listener', t('routeModel.listeneripListenerportAlreadyHasA', { listenerIP, listenerPort }));
if (!fallback && !peer.fallback) {
const duplicate = snis.find((sni) => (peer.snis ?? []).map((value) => value.toLowerCase()).includes(sni));
- if (duplicate) add('snis', `SNI ${duplicate} уже назначен другому маршруту на ${listenerIP}:${listenerPort}.`);
+ if (duplicate) add('snis', t('routeModel.sniDuplicateIsAlreadyAssigned', { duplicate, listenerIP, listenerPort }));
}
// Kernel shaping classifies by listener + client IP (no SNI): every kernel-shaped
// route on one listener must share limits, and kernel/HAProxy shaping cannot mix.
@@ -1086,12 +1087,12 @@ export function validateRouteDraft(draft: RouteDraft, peers: RouteRecord[], edit
const peerKernel = peer.shaper_mode === 'kernel';
const peerName = routeDisplayName(peer);
if ((draft.shaperMode === 'kernel') !== peerKernel) {
- add('bandwidth', `На ${listenerIP}:${listenerPort} маршрут ${peerName} ограничивает скорость через ${peerKernel ? 'ядро' : 'HAProxy'}; режимы на одном listener смешивать нельзя.`);
+ add('bandwidth', t('routeModel.onListeneripListenerportRoutePeername', { listenerIP, listenerPort, peerName, p3: peerKernel ? t('misc.theKernel') : 'HAProxy' }));
} else if (peerKernel) {
const up = draft.clientUploadMbps === '' ? null : draft.clientUploadMbps;
const down = draft.clientDownloadMbps === '' ? null : draft.clientDownloadMbps;
if (up !== peer.client_upload_mbps || down !== peer.client_download_mbps) {
- add('bandwidth', `Лимиты ядра на ${listenerIP}:${listenerPort} должны совпадать с маршрутом ${peerName} (↑${peer.client_upload_mbps ?? '—'} / ↓${peer.client_download_mbps ?? '—'} Mbps).`);
+ add('bandwidth', t('routeModel.kernelLimitsOnListeneripListenerport', { listenerIP, listenerPort, peerName, p3: peer.client_upload_mbps ?? '—', p4: peer.client_download_mbps ?? '—' }));
}
}
}
@@ -1301,7 +1302,7 @@ export function renderRoutePreview(draft: RouteDraft, peers: RouteRecord[], memo
const needsResolvers = draft.servers.some((s) => s.targetType === 'tcp'
&& (s.dnsPool || (s.host.trim() !== '' && !isIPAddress(s.host.trim()))));
if (needsResolvers) {
- lines.push('resolvers nf_dns', ' nameserver adguard_local 127.0.0.1:53', ' # … остальные nameserver и hold-параметры', '');
+ lines.push('resolvers nf_dns', ' nameserver adguard_local 127.0.0.1:53', ` ${t('routeModel.otherNameserversAndHoldParameters')}`, '');
}
// Emitted in `global`/before the first frontend once ANY backend uses source_table
// (table survives a HAProxy reload); this preview only knows about the edited route.
diff --git a/frontend/src/features/routes/templates.ts b/frontend/src/features/routes/templates.ts
index ec521b5..219fb2c 100644
--- a/frontend/src/features/routes/templates.ts
+++ b/frontend/src/features/routes/templates.ts
@@ -4,9 +4,11 @@ import { randomUUID } from '../../lib/uuid';
import { routePayload, routeToDraft } from './model';
import { templateDraft, type RouteTemplate } from './templateModel';
+import { t } from '../../i18n';
export { templateDraft, type RouteTemplate } from './templateModel';
-export interface CopyResult { node_id: string; route?: RouteRecord; error?: string }
+/** error_code is stable (localized by the UI); error is the server's text, used for unknown codes. */
+export interface CopyResult { node_id: string; route?: RouteRecord; error?: string; error_code?: string }
const key = 'nodeflow:demo-route-templates';
const demo = () => new URLSearchParams(window.location.search).get('demo') === '1';
const demoTemplates = (): RouteTemplate[] => JSON.parse(localStorage.getItem(key) || '[]');
@@ -15,8 +17,8 @@ export async function listTemplates(): Promise {
}
export async function getTemplate(id: string): Promise {
if (!demo()) return api(`/api/v1/route-templates/${encodeURIComponent(id)}`);
- const value = demoTemplates().find(t => t.id === id);
- if (!value) throw new Error('Шаблон не найден');
+ const value = demoTemplates().find(tpl => tpl.id === id);
+ if (!value) throw new Error(t('templates.templateNotFound'));
return value;
}
export async function saveTemplate(name: string, description: string, config: ReturnType, previous?: RouteTemplate): Promise {
@@ -25,7 +27,7 @@ export async function saveTemplate(name: string, description: string, config: Re
});
const list = demoTemplates();
const value = { id: previous?.id || randomUUID(), name, description, config, version: (previous?.version || 0) + 1 };
- localStorage.setItem(key, JSON.stringify([...list.filter(t => t.id !== value.id), value]));
+ localStorage.setItem(key, JSON.stringify([...list.filter(tpl => tpl.id !== value.id), value]));
return value;
}
export async function saveRouteTemplate(source: RouteRecord, name: string, description: string): Promise {
@@ -34,7 +36,7 @@ export async function saveRouteTemplate(source: RouteRecord, name: string, descr
}
export async function deleteTemplate(template: RouteTemplate): Promise {
if (!demo()) return api(`/api/v1/route-templates/${template.id}`, { method: 'DELETE', body: JSON.stringify({ expected_version: template.version }) });
- localStorage.setItem(key, JSON.stringify(demoTemplates().filter(t => t.id !== template.id)));
+ localStorage.setItem(key, JSON.stringify(demoTemplates().filter(tpl => tpl.id !== template.id)));
}
export async function templateNodes(): Promise {
if (!demo()) return api('/api/v1/nodes');
@@ -49,7 +51,7 @@ export async function copyRoutes(source: RouteRecord | RouteTemplate, nodes: str
const { upsertDemoRoute } = await import('../../fixtures/demo');
return nodes.map(node_id => {
const draft = isTemplate ? templateDraft(source) : routeToDraft(source);
- const payload = routePayload({ ...draft, name: name || (node_id === (isTemplate ? '' : source.node_id) ? `${draft.name} (копия)` : draft.name) }, false);
+ const payload = routePayload({ ...draft, name: name || (node_id === (isTemplate ? '' : source.node_id) ? t('templates.nameCopy', { name: draft.name }) : draft.name) }, false);
const route = { ...payload, id: randomUUID(), node_id, version: 1, enabled: false, deployment_state: 'draft', snis: payload.snis } as unknown as RouteRecord;
upsertDemoRoute(node_id, route);
return { node_id, route };
diff --git a/frontend/src/features/settings/settings-ui.tsx b/frontend/src/features/settings/settings-ui.tsx
index 20800d1..fe0f190 100644
--- a/frontend/src/features/settings/settings-ui.tsx
+++ b/frontend/src/features/settings/settings-ui.tsx
@@ -12,6 +12,7 @@
*/
import { NumberInput, type NumberInputProps } from '@mantine/core';
import type { ReactNode } from 'react';
+import { locale } from '../../i18n';
/**
* Compact field: label above the control, hint under the label. Fields sit
@@ -62,8 +63,8 @@ export function CfgValue({ children, mono = true }: { children: ReactNode; mono?
return {children} ;
}
-const dateFormat = new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' });
-const dateTimeFormat = new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
+const dateFormat = new Intl.DateTimeFormat(locale(), { day: 'numeric', month: 'short', year: 'numeric' });
+const dateTimeFormat = new Intl.DateTimeFormat(locale(), { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
export function formatDay(value: string): string {
const date = new Date(value);
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
new file mode 100644
index 0000000..5d599ba
--- /dev/null
+++ b/frontend/src/i18n.ts
@@ -0,0 +1,89 @@
+/**
+ * Lightweight UI localization: typed flat dictionaries (locales/ru.ts is the
+ * source of truth for keys, locales/en.ts must have exactly the same keys) and
+ * a module-level `t()` usable from components and plain modules alike.
+ *
+ * The language is resolved once per page load (localStorage → browser
+ * languages → ru). Switching the language persists the choice and reloads the
+ * page, so module-level label tables built with `t()` are always consistent.
+ * Missing English entries fall back to Russian.
+ */
+import { en } from './locales/en';
+import { ru, type MessageKey, type Message } from './locales/ru';
+
+export type Lang = 'ru' | 'en';
+export type { MessageKey };
+export const LANGS: readonly Lang[] = ['ru', 'en'];
+export const LANG_STORAGE_KEY = 'nodeflow.lang';
+
+const dictionaries: Record>> = { ru, en };
+
+function storage(): Storage | null {
+ try { return typeof localStorage === 'undefined' ? null : localStorage; } catch { return null; }
+}
+
+export function detectLang(stored: string | null | undefined, browser: readonly string[] = []): Lang {
+ if (stored === 'ru' || stored === 'en') return stored;
+ for (const tag of browser) {
+ const base = tag.toLowerCase().split('-')[0];
+ if (base === 'ru' || base === 'en') return base;
+ }
+ return 'ru';
+}
+
+function initialLang(): Lang {
+ // Only a real browser contributes navigator languages (Node test runs stay ru).
+ const nav = typeof window === 'undefined' || typeof navigator === 'undefined' ? [] : navigator.languages?.length ? navigator.languages : [navigator.language];
+ return detectLang(storage()?.getItem(LANG_STORAGE_KEY), nav.filter(Boolean));
+}
+
+let current: Lang = initialLang();
+if (typeof document !== 'undefined') document.documentElement.lang = current;
+
+export const getLang = (): Lang => current;
+/** BCP 47 locale for Intl number/date formatting. */
+export const locale = (): string => (current === 'en' ? 'en-US' : 'ru-RU');
+
+/** Persists the language and reloads so every label table is rebuilt. */
+export function setLang(lang: Lang, reload = true): void {
+ storage()?.setItem(LANG_STORAGE_KEY, lang);
+ if (lang === current) return;
+ current = lang;
+ if (typeof document !== 'undefined') document.documentElement.lang = lang;
+ if (reload && typeof window !== 'undefined') window.location.reload();
+}
+
+export type Params = Record;
+
+const pluralRules: Partial> = {};
+function pick(message: Message, lang: Lang, params?: Params): string {
+ if (typeof message === 'string') return message;
+ const count = Number(params?.count ?? 0);
+ const rules = (pluralRules[lang] ??= new Intl.PluralRules(lang === 'en' ? 'en-US' : 'ru-RU'));
+ return message[rules.select(count)] ?? message.other ?? message.many ?? Object.values(message)[0] ?? '';
+}
+
+export function translate(lang: Lang, key: MessageKey, params?: Params): string {
+ const message = dictionaries[lang][key] ?? ru[key];
+ if (message === undefined) return key;
+ const text = pick(message, dictionaries[lang][key] === undefined ? 'ru' : lang, params);
+ return params ? text.replace(/\{(\w+)\}/g, (match, name: string) => (params[name] == null ? match : String(params[name]))) : text;
+}
+
+/** Translate a key in the current UI language; `{name}` placeholders take params, `count` selects plural forms. */
+export const t = (key: MessageKey, params?: Params): string => translate(current, key, params);
+
+/** True when `key` exists in the dictionaries (dynamic keys such as API error codes). */
+export const hasKey = (key: string): key is MessageKey => Object.prototype.hasOwnProperty.call(ru, key);
+
+/**
+ * Localized text for a backend error code; unknown codes keep the server's
+ * message (the backend stays the source of detail for validation errors).
+ */
+export function apiErrorText(code: string | undefined, serverMessage: string): string {
+ const key = `apiError.${code ?? ''}`;
+ return code && hasKey(key) ? t(key) : serverMessage;
+}
+
+/** Hook form for components; the language is fixed per page load, so this is just `t`. */
+export const useT = (): typeof t => t;
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 4aa4317..a9b3f5a 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -1,4 +1,5 @@
import type { APIErrorPayload, DNSResolveResult } from './contracts';
+import { apiErrorText, t } from '../i18n';
export class APIError extends Error {
constructor(
@@ -21,7 +22,7 @@ export async function api(path: string, init: RequestInit = {}): Promise {
try {
response = await fetch(path, { ...init, headers, credentials: 'same-origin' });
} catch {
- throw new APIError('Panel API недоступен. Проверьте соединение.', 0);
+ throw new APIError(t('app.panelApiIsUnreachableCheck'), 0);
}
const payload = (await response.json().catch(() => null)) as T | APIErrorPayload | null;
@@ -32,7 +33,7 @@ export async function api(path: string, init: RequestInit = {}): Promise {
const body = payload as APIErrorPayload | null;
const structured = typeof body?.error === 'object' ? body.error : null;
const message = structured?.message ?? (typeof body?.error === 'string' ? body.error : `HTTP ${response.status}`);
- throw new APIError(message, response.status, structured?.code, payload);
+ throw new APIError(apiErrorText(structured?.code, message), response.status, structured?.code, payload);
}
return payload as T;
}
diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts
index 6ded57d..b7aa381 100644
--- a/frontend/src/lib/format.ts
+++ b/frontend/src/lib/format.ts
@@ -1,9 +1,10 @@
+import { t, locale } from '../i18n';
export const formatNumber = (value: number | null | undefined, maximumFractionDigits = 0): string =>
- Number.isFinite(value) ? new Intl.NumberFormat('ru-RU', { maximumFractionDigits }).format(Number(value)) : '—';
+ Number.isFinite(value) ? new Intl.NumberFormat(locale(), { maximumFractionDigits }).format(Number(value)) : '—';
export function formatBytes(value: number | null | undefined): string {
if (!Number.isFinite(value)) return '—';
- const units = ['Б', 'КиБ', 'МиБ', 'ГиБ', 'ТиБ', 'ПиБ'];
+ const units = [t('format.b'), t('format.kib'), t('format.mib'), t('format.gib'), t('format.tib'), t('format.pib')];
let result = Math.max(0, Number(value));
let index = 0;
while (result >= 1024 && index < units.length - 1) {
@@ -15,7 +16,7 @@ export function formatBytes(value: number | null | undefined): string {
export function formatBitrate(value: number | null | undefined): string {
if (typeof value !== 'number' || !Number.isFinite(value)) return '—';
- const units = ['бит/с', 'Кбит/с', 'Мбит/с', 'Гбит/с', 'Тбит/с'];
+ const units = [t('format.bitS'), t('format.kbitS'), t('format.mbitS'), t('format.gbitS'), t('format.tbitS')];
let result = Math.max(0, Number(value));
let index = 0;
while (result >= 1000 && index < units.length - 1) {
@@ -26,9 +27,9 @@ export function formatBitrate(value: number | null | undefined): string {
}
export function formatMonth(value?: string): string {
- if (!/^\d{4}-\d{2}$/.test(value ?? '')) return 'текущий месяц';
+ if (!/^\d{4}-\d{2}$/.test(value ?? '')) return t('format.currentMonth');
const [year, month] = value!.split('-').map(Number);
- return new Intl.DateTimeFormat('ru-RU', { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(
+ return new Intl.DateTimeFormat(locale(), { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(
new Date(Date.UTC(year, month - 1, 1)),
);
}
@@ -39,12 +40,12 @@ export function shortHAProxy(value?: string): string {
}
export function timeAgo(value?: string): string {
- if (!value) return 'никогда';
+ if (!value) return t('format.never');
const seconds = Math.max(0, (Date.now() - Date.parse(value)) / 1000);
- if (seconds < 60) return `${Math.floor(seconds)} сек назад`;
- if (seconds < 3600) return `${Math.floor(seconds / 60)} мин назад`;
- if (seconds < 86400) return `${Math.floor(seconds / 3600)} ч назад`;
- return `${Math.floor(seconds / 86400)} д назад`;
+ if (seconds < 60) return t('format.floorSAgo', { floor: Math.floor(seconds) });
+ if (seconds < 3600) return t('format.p0MinAgo', { p0: Math.floor(seconds / 60) });
+ if (seconds < 86400) return t('format.p0HAgo', { p0: Math.floor(seconds / 3600) });
+ return t('format.p0DAgo', { p0: Math.floor(seconds / 86400) });
}
export function formatDuration(value: number | null | undefined): string {
@@ -55,14 +56,14 @@ export function formatDuration(value: number | null | undefined): string {
const hours = Math.floor(seconds / 3_600);
seconds %= 3_600;
const minutes = Math.floor(seconds / 60);
- if (days) return `${days} д. ${hours} ч. ${minutes} м.`;
- if (hours) return `${hours} ч. ${minutes} м.`;
- return `${minutes} мин.`;
+ if (days) return t('format.daysDHoursHMinutes', { days, hours, minutes });
+ if (hours) return t('format.hoursHMinutesM', { hours, minutes });
+ return t('format.minutesMin', { minutes });
}
export function formatDateTime(value?: string): string {
if (!value || !Number.isFinite(Date.parse(value))) return '—';
- return new Intl.DateTimeFormat('ru-RU', {
+ return new Intl.DateTimeFormat(locale(), {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
}).format(new Date(value));
}
diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts
new file mode 100644
index 0000000..1eb7911
--- /dev/null
+++ b/frontend/src/locales/en.ts
@@ -0,0 +1,806 @@
+/** English UI strings. Must have exactly the keys of ru.ts (checked by tsc and tests/i18n.test.ts). */
+import type { Message, MessageKey } from './ru';
+
+export const en: Record = {
+ // ── app
+ "app.loadingInterface": "Loading interface",
+ "app.loadingNode": "Loading node",
+ "app.loadingConfigurationEditor": "Loading configuration editor",
+ "app.loadingRouteEditor": "Loading route editor",
+ "app.loadingTrafficStatistics": "Loading traffic statistics",
+ "app.loadingSettings": "Loading settings",
+ "app.nodes": "Nodes",
+ "app.traffic": "Traffic",
+ "app.settings": "Settings",
+ "app.checkingSession": "Checking session",
+ "app.closeMenu": "Close menu",
+ "app.openMenu": "Open menu",
+ "app.mainNavigation": "Main navigation",
+ "app.operator": "Operator",
+ "app.localOperator": "Local operator",
+ "app.signOut": "Sign out",
+ "app.signInFailed": "Sign-in failed",
+ "app.signInToThePanel": "Sign in to the panel",
+ "app.useTheAdminTokenAfter": "Use the admin token. After sign-in it is replaced with a secure browser session.",
+ "app.panelToken": "Panel token",
+ "app.signIn": "Sign in",
+ "app.breadcrumbs": "Breadcrumbs",
+ "app.retry": "Retry",
+ "app.panelApiIsUnreachableCheck": "Panel API is unreachable. Check your connection.",
+
+ // ── format
+ "format.b": "B",
+ "format.kib": "KiB",
+ "format.mib": "MiB",
+ "format.gib": "GiB",
+ "format.tib": "TiB",
+ "format.pib": "PiB",
+ "format.bitS": "bit/s",
+ "format.kbitS": "kbit/s",
+ "format.mbitS": "Mbit/s",
+ "format.gbitS": "Gbit/s",
+ "format.tbitS": "Tbit/s",
+ "format.currentMonth": "current month",
+ "format.never": "never",
+ "format.floorSAgo": "{floor} s ago",
+ "format.p0MinAgo": "{p0} min ago",
+ "format.p0HAgo": "{p0} h ago",
+ "format.p0DAgo": "{p0} d ago",
+ "format.daysDHoursHMinutes": "{days} d {hours} h {minutes} m",
+ "format.hoursHMinutesM": "{hours} h {minutes} m",
+ "format.minutesMin": "{minutes} min",
+
+ // ── nodes
+ "nodes.dataIsStale": "Data is stale",
+ "nodes.noFreshData": "No fresh data",
+ "nodes.currentDataIsIncomplete": "Current data is incomplete",
+ "nodes.nodeIsUnreachable": "Node is unreachable",
+ "nodes.nodeIsDegraded": "Node is degraded",
+ "nodes.online": "Online",
+ "nodes.nodesAreUnreachable": "Nodes are unreachable",
+ "nodes.partiallyAvailable": "Partially available",
+ "nodes.nodeOrderNotSaved": "Node order not saved",
+ "nodes.searchNodes": "Search nodes...",
+ "nodes.searchNodes2": "Search nodes",
+ "nodes.refresh": "Refresh",
+ "nodes.addNode": "Add node",
+ "nodes.sourceReset": "Source reset",
+ "nodes.dismissNotice": "Dismiss notice",
+ "nodes.theSelectedNodeIsNo": "The selected node is no longer available. Showing the summary for all nodes.",
+ "nodes.failedToLoadNodes": "Failed to load nodes",
+ "nodes.panelApiIsUnreachable": "Panel API is unreachable",
+ "nodes.noNodesYet": "No nodes yet",
+ "nodes.addAServerOverSsh": "Add a server over SSH — the panel will install Node Agent and HAProxy.",
+ "nodes.allNodes": "All nodes",
+ "nodes.trafficSource": "Traffic source",
+ "nodes.statusFilter": "Status filter",
+ "nodes.online2": "Online",
+ "nodes.degraded": "Degraded",
+ "nodes.unreachable": "Unreachable",
+ "nodes.searchInTable": "Search in table",
+ "nodes.toggleRowDensity": "Toggle row density",
+ "nodes.close": "Close",
+ "nodes.clearSearchAndFiltersTo": "Clear search and filters to reorder.",
+ "nodes.reorderingIsOnlyAvailableOn": "Reordering is only available on the first page.",
+ "nodes.nothingFound": "Nothing found",
+ "nodes.changeTheSearchOrStatus": "Change the search or status filter.",
+ "nodes.filters": "Filters",
+ "nodes.nodeStatus": "Node status",
+ "nodes.online3": "Online",
+ "nodes.degraded2": "Degraded",
+ "nodes.unreachable2": "Unreachable",
+ "nodes.openNodeName": "Open node {name}",
+ "nodes.dragNode": "Drag node",
+ "nodes.dragNodeName": "Drag node {name}",
+ "nodes.moveNameUp": "Move {name} up",
+ "nodes.moveNameDown": "Move {name} down",
+ "nodes.seen": "seen",
+ "nodes.noRate": "no rate",
+ "nodes.connectionRateNewS": "{connection_rate} new/s",
+ "nodes.noLimit": "no limit",
+ "nodes.roundPercentOfQuotaUsed": "{round} percent of quota used",
+ "nodes.trafficWithoutLimit": "Traffic without limit",
+ "nodes.noData": "no data",
+ "nodes.actionsName": "Actions: {name}",
+ "nodes.openNode": "Open node",
+ "nodes.addRoute": "Add route",
+ "nodes.nodeManagement": "Node management",
+ "nodes.status": "Status",
+ "nodes.node": "Node",
+ "nodes.routes": "Routes",
+ "nodes.connections": "Connections",
+ "nodes.monthlyTraffic": "Monthly traffic",
+ "nodes.actions": "Actions",
+ "nodes.showing": "Showing",
+ "nodes.totalThroughput": "Total throughput",
+ "nodes.incompleteData": "Incomplete data",
+ "nodes.rxTotal": "RX total",
+ "nodes.txTotal": "TX total",
+ "nodes.ofFormatbytes": "of {formatBytes}",
+ "nodes.noSharedLimit": "no shared limit",
+ "nodes.roundUsed": "{round}% used",
+ "nodes.noSharedLimitSet": "No shared limit set",
+ "nodes.noQuotaSet": "No quota set",
+ "nodes.noCompleteCurrentSample": "no complete current sample",
+ "nodes.activeCurrentSnapshot": "active · current snapshot",
+ "nodes.unavailable": "Unavailable",
+ "nodes.allTrafficP0ListenerPort": "All traffic · {p0}:{listener_port}",
+ "nodes.routeP0ListenerPort": "Route · {p0}:{listener_port}",
+ "nodes.topRoutes": "Top routes",
+ "nodes.route": "Route",
+ "nodes.thisMonthUsedBytes": "This month: {used_bytes}",
+ "nodes.noRoutesYet": "No routes yet",
+ "nodes.viewAllRoutes": "View all routes",
+ "nodes.n1m": "1m",
+ "nodes.n5m": "5m",
+ "nodes.n1h": "1h",
+ "nodes.n24h": "24h",
+ "nodes.n7d": "7d",
+ "nodes.n30d": "30d",
+ "nodes.collectingTrafficHistory": "Collecting traffic history",
+ "nodes.theFirstSampleWillAppear": "The first sample will appear after a Node Agent heartbeat.",
+ "nodes.waitingForTheFirstSample": "Waiting for the first sample",
+ "nodes.updatedP0": "Updated: {p0}",
+ "nodes.now": "Now",
+ "nodes.latestSample": "Latest sample",
+ "nodes.trafficForP0ValuescopeRx": "Traffic for {p0}. {valueScope} RX {formatBitrate}, TX {p3}.",
+ "nodes.historicalData": "Historical data",
+ "nodes.currentThroughput": "Current throughput",
+ "nodes.latestHistorySample": "Latest history sample",
+ "nodes.chartPeriod": "Chart period",
+ "nodes.unnamed": "Unnamed",
+
+ // ── node
+ "node.needsAttention": "Needs attention",
+ "node.haproxyIsStopped": "HAProxy is stopped",
+ "node.nodeFailedToLoad": "Node failed to load",
+ "node.nodeNotFound": "Node not found",
+ "node.backToNodes": "Back to nodes",
+ "node.noCurrentSample": "No current sample",
+ "node.nodeNotDeleted": "Node not deleted",
+ "node.enterTheNodeNameAnd": "Enter the node name and IP address.",
+ "node.fixTheHaproxySettings": "Fix the HAProxy settings.",
+ "node.nodeSettingsNotSaved": "Node settings not saved",
+ "node.routeNotUpdated": "Route not updated",
+ "node.routeNotDeleted": "Route not deleted",
+ "node.routeOrderNotSaved": "Route order not saved",
+ "node.haproxyNotP0": "HAProxy not {p0}",
+ "node.haproxyNotRestarted": "HAProxy not restarted",
+ "node.ufwModeNotChanged": "UFW mode not changed",
+ "node.nodeAgentStatusIsUnavailable": "Node Agent status is unavailable. Refresh and try again.",
+ "node.releaseNotScheduled": "Release not scheduled",
+ "node.rollbackNotScheduled": "Rollback not scheduled",
+ "node.lastHeartbeat": "Last heartbeat:",
+ "node.routeTemplates": "Route templates",
+ "node.haproxyConfiguration": "HAProxy configuration",
+ "node.nodeSettings": "Node settings",
+ "node.nodeActions": "Node actions",
+ "node.refreshData": "Refresh data",
+ "node.editNode": "Edit node",
+ "node.reinstallNodeAgent": "Reinstall Node Agent",
+ "node.hardRestartHaproxy": "Hard restart HAProxy",
+ "node.stopHaproxy": "Stop HAProxy",
+ "node.startHaproxy": "Start HAProxy",
+ "node.deleteNode": "Delete node",
+ "node.nodeIsNotResponding": "Node is not responding.",
+ "node.nodeIsRunningWithLimitations": "Node is running with limitations.",
+ "node.showingTheLastSavedValues": "Showing the last saved values; control actions may wait until the connection is restored.",
+ "node.checkTheHaproxyRuntimeBackend": "Check the HAProxy runtime, backend health and route apply status.",
+ "node.haproxyIsStopped2": "HAProxy is stopped.",
+ "node.nodeAgentStaysConnectedBut": "Node Agent stays connected, but listener ports and all routes are stopped.",
+ "node.haproxyControlIsUnavailable": "HAProxy control is unavailable.",
+ "node.updateNodeAgentToA": "Update Node Agent to a version that supports starting and stopping the service.",
+ "node.systemStatisticsAreTemporarilyUnavailable": "System statistics are temporarily unavailable:",
+ "node.nodeTraffic": "Node traffic",
+ "node.historyUnavailableHistory": "History unavailable: {history}",
+ "node.newRangeNotLoadedP0": "New range not loaded: {p0}",
+ "node.updatingTheRangeWithoutReloading": "Updating the range without reloading the page…",
+ "node.stopHaproxy2": "Stop HAProxy?",
+ "node.allRoutesOnThisNode": "All routes on this node will stop.",
+ "node.nodeAgentWillStayConnected": "Node Agent will stay connected. You can start HAProxy again from the same menu.",
+ "node.lastError": "Last error:",
+ "node.cancel": "Cancel",
+ "node.hardRestartHaproxy2": "Hard restart HAProxy?",
+ "node.allActiveConnectionsWillBe": "All active connections will be dropped immediately.",
+ "node.thisIsASystemdRestart": "This is a systemd restart, not a graceful reload. Clients will have to reconnect.",
+ "node.hardRestart": "Hard restart",
+ "node.nodeName": "Node name",
+ "node.nodeIpAddress": "Node IP address",
+ "node.haproxyConnectionLogs": "HAProxy connection logs",
+ "node.everyConnectionIsWrittenTo": "Every connection is written to syslog. Turn off on small disks.",
+ "node.auto": "auto",
+ "node.emptyDefaultValueAppliedTo": "Empty = default value. Applied to all routes on the node with a graceful reload.",
+ "node.reinstallNodeAgent2": "Reinstall Node Agent",
+ "node.nodeflowWillReVerifyThe": "NodeFlow will re-verify the host SSH key fingerprint, install the Agent and wait for a new mTLS heartbeat. The node ID and routes stay the same; on failure the previous credentials are restored automatically.",
+ "node.reinstallAgent": "Reinstall Agent",
+ "node.save": "Save",
+ "node.deleteNode2": "Delete node?",
+ "node.theRecord": "The record",
+ "node.andRelatedDataWillBe": "and related data will be removed from the Panel. Node Agent and HAProxy remain installed on the server.",
+ "node.toConfirmType": "To confirm, type",
+ "node.labelHistoryIsStillBeing": "{label}: history is still being collected",
+ "node.deltaUnavailable": "delta unavailable",
+ "node.deltaP0Formatter": "delta {p0}{formatter}",
+ "node.labelAverageFormatterDeltacopySamples": "{label}: average {formatter}, {deltaCopy}, samples {sampleCount}",
+ "node.processesFormatnumberP1": "Processes: {formatNumber}{p1}",
+ "node.mainProcessesP0": "Main processes: {p0}",
+ "node.processListNotReceivedYet": "Process list not received yet",
+ "node.systemNetworkAndConnections": "System, network and connections",
+ "node.noData": "No data",
+ "node.p0Used": "{p0}% used",
+ "node.normalizedLinuxLoadAverageOver": "Normalized Linux load average over 1, 5 and 15 minutes",
+ "node.load1m5m15m": "Load (1m / 5m / 15m)",
+ "node.currentSnapshot": "current snapshot",
+ "node.latestSampleTimeago": "latest sample {timeAgo}",
+ "node.noFreshSample": "no fresh sample",
+ "node.haproxyNow": "HAProxy now",
+ "node.haproxyRuntimeNotReceivedYet": "HAProxy runtime not received yet",
+ "node.rateAndTotalNotReceived": "Rate and total not received",
+ "node.formatnumberTcpSessionsTotal": "{formatNumber} TCP sessions total",
+ "node.tcpSessions": "TCP sessions",
+ "node.totalNotReceived": "Total not received",
+ "node.formatnumberTotal": "{formatNumber} total",
+ "node.uptime": "Uptime",
+ "node.formatnumberProcesses": "{formatNumber} processes",
+ "node.withoutAgentRestart": "without Agent restart",
+ "node.mtlsConnection": "mTLS connection",
+ "node.connected": "Connected",
+ "node.noFreshHeartbeat": "No fresh heartbeat",
+ "node.lastHeartbeatOverTheSecure": "last heartbeat over the secure channel",
+ "node.routeCreated": "Route created",
+ "node.routeChanged": "Route changed",
+ "node.routeEnabled": "Route enabled",
+ "node.routeDisabled": "Route disabled",
+ "node.routeDeleted": "Route deleted",
+ "node.nodeAgentUpdateScheduled": "Node Agent update scheduled",
+ "node.nodeAgentRollbackScheduled": "Node Agent rollback scheduled",
+ "node.ufwPolicyChanged": "UFW policy changed",
+ "node.nodeSettingsChanged": "Node settings changed",
+ "node.installed": "Installed",
+ "node.ready": "Ready",
+ "node.scheduled": "Scheduled",
+ "node.downloading": "Downloading",
+ "node.verifying": "Verifying",
+ "node.installing": "Installing",
+ "node.error": "Error",
+ "node.autoRollback": "Auto-rollback",
+ "node.nodePlatformIsUnknownOr": "Node platform is unknown or has changed. Refresh the status before rolling back.",
+ "node.updatesAndFirewall": "Updates and firewall",
+ "node.platformlabelVersionAvailable": "{platformLabel} · {version} available",
+ "node.platformlabelUpToDate": "{platformLabel} · up to date",
+ "node.platformNotReceived": "platform not received",
+ "node.selectVersion": "Select version",
+ "node.rollBack": "Roll back",
+ "node.releaseSchedulingIsBlockedThe": "Release scheduling is blocked: the Agent has not reported os and arch yet.",
+ "node.ufwPolicy": "UFW policy",
+ "node.autoOpenAllowed": "Auto-open allowed",
+ "node.checkOnly": "Check only",
+ "node.notManaged": "Not managed",
+ "node.plannedPorts": "Planned ports",
+ "node.noneActive": "none active",
+ "node.off": "Off",
+ "node.auto2": "Auto",
+ "node.ufwManagementPolicy": "UFW management policy",
+ "node.advancedNodeSettings": "Advanced node settings",
+ "node.recentActions": "Recent actions",
+ "node.recentActionsScrollableList": "Recent actions, scrollable list",
+ "node.succeeded": "Succeeded",
+ "node.noActionsYet": "No actions yet.",
+ "node.versionsAndConnection": "Versions and connection",
+ "node.fullVersionNotReceived": "Full version not received",
+ "node.panelConnection": "Panel connection",
+ "node.outboundMtls": "Outbound mTLS · :",
+ "node.lastAuthorization": "Last authorization",
+ "node.updatePolicy": "Update policy",
+ "node.ed25519AutoRollback": "Ed25519 · auto-rollback",
+ "node.rollBackNodeAgent": "Roll back Node Agent?",
+ "node.theSignedRelease": "The signed release",
+ "node.for": "for",
+ "node.willBeScheduledTheUpdater": "will be scheduled. The updater will verify Ed25519 and SHA-256 and restore the current binary if it fails to start.",
+ "node.active": "Active",
+ "node.applying": "Applying",
+ "node.draft": "Draft",
+ "node.disabled": "Disabled",
+ "node.deleting": "Deleting",
+ "node.fallback": "Fallback",
+ "node.dnsPoolTarget": "DNS pool · {target}",
+ "node.haproxyBackendP0ServersHealthy": "HAProxy backend: {p0} · servers {healthy}/{length}",
+ "node.backendNotObservedYet": "Backend not observed yet",
+ "node.serverMapNotReceived": "Server map not received",
+ "node.haproxyBackendLabelServerMap": "HAProxy backend: {label} · server map not received",
+ "node.editRouteName": "Edit route {name}",
+ "node.p0RouteName": "{p0} route {name}",
+ "node.dragRoute": "Drag route",
+ "node.dragRouteName": "Drag route {name}",
+ "node.routeActionsName": "Route actions {name}",
+ "node.statistics": "Statistics",
+ "node.edit": "Edit",
+ "node.duplicateRoute": "Duplicate route",
+ "node.copyToNodes": "Copy to nodes",
+ "node.saveAsTemplate": "Save as template",
+ "node.delete": "Delete",
+ "node.waitForTheCurrentChange": "Wait for the current change to finish.",
+ "node.routeHistoryUnavailable": "Route history unavailable",
+ "node.routeFilter": "Route filter",
+ "node.all": "All",
+ "node.enabled": "Enabled",
+ "node.drafts": "Drafts",
+ "node.searchRoutes": "Search routes...",
+ "node.searchRoutes2": "Search routes",
+ "node.status": "Status",
+ "node.allRoutes": "All routes",
+ "node.enabledOnly": "Enabled only",
+ "node.draftsOnly": "Drafts only",
+ "node.failedFailed": "Failed ({failed})",
+ "node.nodeRoutes": "Node routes",
+ "node.listensOn": "Listens on",
+ "node.matchRule": "Match rule",
+ "node.destination": "Destination",
+ "node.backends": "Backends",
+ "node.unmatchedConnectionsAreDropped": "unmatched connections are dropped",
+ "node.createAListenerTargetA": "Create a listener → target. A new route is saved as a disabled draft.",
+ "node.deleteRoute": "Delete route?",
+ "node.willFirstBeSafelyRemoved": "will first be safely removed from HAProxy, then deleted once the Agent confirms.",
+ "node.statisticsRoutename": "Statistics · {routeName}",
+ "node.routeStatistics": "Route statistics",
+ "node.loadingHistory": "Loading history…",
+ "node.historyUnavailable": "History unavailable",
+ "node.noSamplesForTheSelected": "No samples for the selected period",
+ "node.theNewSampleSetWill": "The new sample set will replace the previous period.",
+ "node.retryTheRequestOrPick": "Retry the request or pick another period.",
+ "node.haproxyReportedNoSamplesFor": "HAProxy reported no samples for this route and range.",
+ "node.requestInProgress": "Request in progress",
+ "node.requestFailed": "Request failed",
+ "node.noSamples": "No samples",
+ "node.loadError": "Load error",
+ "node.dataUnavailable": "Data unavailable",
+ "node.integer1P0": "Integer 1–{p0}",
+ "node.format5s15m": "Format: 5s, 15m",
+
+ // ── misc
+ "misc.dataUnavailable": "data unavailable",
+ "misc.started": "started",
+ "misc.stopped": "stopped",
+ "misc.total": "— total",
+ "misc.newS": "— new/s",
+ "misc.formatnumberNewS": "{formatNumber} new/s",
+ "misc.totalNotReceived": "total not received",
+ "misc.rateNotReceived": "Rate not received",
+ "misc.mainP0": ". Main: {p0}",
+ "misc.disable": "Disable",
+ "misc.enable": "Enable",
+ "misc.theHealthCheckSendsThe": "The health check sends the header too.",
+ "misc.kernel": "· kernel",
+ "misc.chooseHaproxy": "Choose HAProxy.",
+ "misc.theKernel": "the kernel",
+
+ // ── editor
+ "editor.theKernelShaperRequiresNode": "The kernel shaper requires Node Agent {KERNEL_SHAPER_MIN_AGENT} on this node",
+ "editor.name": "name",
+ "editor.port": "port",
+ "editor.nodeIp": "node IP",
+ "editor.trafficLimit": "traffic limit",
+ "editor.speed": "speed",
+ "editor.serverAddress": "server address",
+ "editor.servers": "servers",
+ "editor.checkTheFormFields": "Check the form fields",
+ "editor.p0AndP1": "{p0} and {p1}",
+ "editor.fillInList": "Fill in {list}",
+ "editor.draftSaved": "Draft saved",
+ "editor.routeCreatedAsADisabled": "Route created as a disabled draft",
+ "editor.changesSentToTheNode": "Changes sent to the node",
+ "editor.routeCreatedAndSentFor": "Route created and sent for enabling",
+ "editor.theDomainDidNotResolve": "The domain did not resolve to any address.",
+ "editor.invalidDomainName": "Invalid domain name.",
+ "editor.failedToResolveAddresses": "Failed to resolve addresses.",
+ "editor.firstEnterADomainIn": "First enter a domain in the “Address” field",
+ "editor.domainIps": "Domain IPs",
+ "editor.preferredIp": "Preferred IP",
+ "editor.allTrafficGoesToThis": "All traffic goes to this IP; if it is down — to all domain IPs and the backup servers.",
+ "editor.allTrafficGoesToThis2": "All traffic goes to this IP; if it is down — to all domain IPs.",
+ "editor.notSet": "not set",
+ "editor.invalidIpAddress": "Invalid IP address.",
+ "editor.ipAddressesOfHost": "IP addresses of {host}",
+ "editor.addressesWereResolvedOnThe": "Addresses were resolved on the panel host — the node may see different ones.",
+ "editor.shareOfNewClientsFor": "Share of new clients for this IP: 2 — twice as many as an IP with weight 1. Empty — the server weight (default 1).",
+ "editor.latencyMultiplierForThisIp": "Latency multiplier for this IP: 2 — the IP looks twice as slow and is picked less often. Empty — the server cost (default 1).",
+ "editor.newClientsAreSplitIn": "New clients are split in proportion to weight: 4 cores vs 2 — give the 4-core one weight 2 and it gets twice as many clients. With stickiness, already pinned clients stay on their IP.",
+ "editor.theLowerLatencyCostThe": "The lower latency × cost, the more clients an IP gets; weight sets the share at equal latency. To match server capacity: 4 cores vs 2 — give the 4-core one weight 2.",
+ "editor.anIpSShareIs": "An IP's share is proportional to its weight. For example, 4 cores vs 2: give the 4-core one weight 2 and it gets twice as many clients.",
+ "editor.perIpWeightAndCost": "Per-IP weight and cost",
+ "editor.perIpWeight": "Per-IP weight",
+ "editor.emptySameAsTheServer": "Empty — same as the server.",
+ "editor.weightsAreCurrentlyFixedAnd": "Weights are currently fixed and will not apply.",
+ "editor.perIpSettingsLabel": "Per-IP settings {label}",
+ "editor.weight": "Weight",
+ "editor.cost": "Cost",
+ "editor.weightIp": "Weight {ip}",
+ "editor.costIp": "Cost {ip}",
+ "editor.removeIp": "Remove {ip}",
+ "editor.ipAddress": "IP address",
+ "editor.addIpForLabel": "Add IP for {label}",
+ "editor.add": "Add",
+ "editor.thisIpIsAlreadyIn": "This IP is already in the list.",
+ "editor.preferredIp2": "preferred IP",
+ "editor.dnsPool": "DNS pool",
+ "editor.weight2": "weight",
+ "editor.cost2": "cost",
+ "editor.twoServersOrADns": "Two servers or a DNS pool are required in “Pool” mode",
+ "editor.name2": "Name",
+ "editor.type": "Type",
+ "editor.address": "Address",
+ "editor.port2": "Port",
+ "editor.haproxyResolvesTheDomainAnd": "HAProxy resolves the domain and balances traffic across all of its IPs. Domains only.",
+ "editor.role": "Role",
+ "editor.shareOfNewClientsRelative": "Share of new clients relative to other servers: 2 — twice as many as a server with weight 1. Empty — 1.",
+ "editor.weightTolowercase": "Weight: {toLowerCase}.",
+ "editor.latencyMultiplier2TheServer": "Latency multiplier: 2 — the server looks twice as slow. Empty — 1.",
+ "editor.serverP0": "server {p0}",
+ "editor.ipDomain": "IP / domain",
+ "editor.ipOrDomain": "IP or domain",
+ "editor.dnsPoolIsAvailableFor": "DNS pool is available for domains only",
+ "editor.dnsPoolForLabelAll": "DNS pool for {label}: all domain IPs",
+ "editor.socketPath": "Socket path",
+ "editor.backup": "Backup",
+ "editor.primary": "Primary",
+ "editor.actionsLabel": "Actions: {label}",
+ "editor.up": "Up",
+ "editor.down": "Down",
+ "editor.makePrimary": "Make primary",
+ "editor.makeBackup": "Make backup",
+ "editor.removeServer": "Remove server",
+ "editor.addServer": "Add server",
+ "editor.maximum16Servers": "Maximum 16 servers.",
+ "editor.loadIsSplitAcrossAll": "Load is split across all domain IPs.",
+ "editor.loadIsSplitAcrossAll2": "Load is split across all servers and all IPs of DNS-pool domains.",
+ "editor.trafficGoesToThePreferred": "Traffic goes to the preferred IP; if it is down — it is split across all domain IPs.",
+ "editor.setAPreferredIpIt": "Set a preferred IP — it gets all traffic, the other domain IPs become backups.",
+ "editor.trafficIsSplitAcrossThe": "Traffic is split across the primary servers",
+ "editor.trafficGoesToThePrimary": "Traffic goes to the primary server",
+ "editor.ifAllPrimariesAreDown": "if all primaries are down",
+ "editor.ifItIsDown": "if it is down",
+ "editor.headFallenItIsSplit": "{head}; {fallen} — it is split across all backups at once.",
+ "editor.headBackupsTakeOverIn": "{head}; backups take over in order, {fallen}.",
+ "editor.firstServer": "First server",
+ "editor.algorithm": "Algorithm",
+ "editor.howToPickAServer": "How to pick a server for a new client.",
+ "editor.dynamicWeights": "Dynamic weights",
+ "editor.offWeightsAreFixedPer": "Off — weights are fixed: per-IP weight and slow start have no effect.",
+ "editor.draws": "Draws",
+ "editor.theLeastLoadedOfThis": "The least loaded of this many random servers is chosen.",
+ "editor.tolerance": "Tolerance",
+ "editor.serversWhoseConnectionsPerUnit": "Servers whose connections per unit of weight exceed the minimum by no more than this are treated as equal.",
+ "editor.empty0": "Empty — 0",
+ "editor.withBackupServersPlainLeastconn": "With backup servers plain leastconn is used: keep tolerance at 0 %.",
+ "editor.weightIsSetInThe": "Weight is set in the server table. Tolerance above 0 is applied by the node agent every 5 s.",
+ "editor.requiresNodeAgentLeastconnTolerance": " Requires Node Agent {LEASTCONN_TOLERANCE_MIN_AGENT}, the node has {p1}.",
+ "editor.serversNoSlowerThanThe": "Servers no slower than the best by more than this get full weight.",
+ "editor.toleranceAtLeast": "Tolerance at least",
+ "editor.differencesUpToThisMany": "Differences up to this many ms are ignored. Empty — off.",
+ "editor.ms": "ms",
+ "editor.off": "off",
+ "editor.latencyIsMeasuredByThe": "Latency is measured by the health check; the node agent updates weights every 5 s. Cost is set in the server table.",
+ "editor.requiresNodeAgentKernelShaper": " Requires Node Agent {KERNEL_SHAPER_MIN_AGENT}, the node has {p1}.",
+ "editor.ipv6Clients": "IPv6 clients",
+ "editor.turnOffIfTheNode": "Turn off if the node has no IPv6: the table will be IPv4-only.",
+ "editor.on": "On",
+ "editor.off2": "Off",
+ "editor.ipv6ClientSubnet": "IPv6 client subnet",
+ "editor.allAddressesInOneSubnet": "All addresses in one subnet count as one client; /128 — each address separately.",
+ "editor.emptyIpv6default": "Empty — /{ipv6Default}",
+ "editor.clientDistribution": "Client distribution",
+ "editor.whichServerAClientGets": "Which server a client gets: picked anew or always the same.",
+ "editor.distribution": "Distribution",
+ "editor.moreEvenButAlmostAll": "More even, but almost all clients move when servers change.",
+ "editor.onlyTheAffectedServersClients": "Only the affected servers' clients move when servers change.",
+ "editor.stable": "Stable",
+ "editor.even": "Even",
+ "editor.overloadLimit": "Overload limit",
+ "editor.stableDistributionOnly": "Stable distribution only.",
+ "editor.noMoreThanThisOf": "No more than this % of the average load. Empty — off.",
+ "editor.rememberFor": "Remember for",
+ "editor.howLongAClientStays": "How long a client stays on a server after its last connection.",
+ "editor.timeUnit": "Time unit",
+ "editor.quickDuration": "Quick duration",
+ "editor.clientsInMemory": "Clients in memory",
+ "editor.memoryIsUsedAsClients": "Memory is used as clients arrive; an entry is dropped after “Remember for” with no connections. When the table is full, the oldest are evicted.",
+ "editor.memoryWhenFull": "{memory} when full",
+ "editor.set": "Set",
+ "editor.clients": "clients",
+ "editor.theTableSurvivesHaproxyReloads": "The table survives HAProxy reloads.",
+ "editor.aClientThatLandedOn": "A client that landed on a backup stays there until “Remember for” expires, even after the primary servers are back.",
+ "editor.sourceRouteNotFound": "Source route not found",
+ "editor.p0Copy": "{p0} — copy",
+ "editor.routeNotSaved": "Route not saved",
+ "editor.kernelshaperagenthintChooseHaproxyOrUpdate": "{kernelShaperAgentHint}. Choose HAProxy or update the agent.",
+ "editor.theRouteWasChangedIn": "The route was changed in another tab. Refresh and try again.",
+ "editor.draftSavedButTheRoute": "Draft saved, but the route could not be enabled: {detail}",
+ "editor.loadingNodeRoutesAndChecking": "Loading node routes and checking port conflicts…",
+ "editor.editorFailedToLoad": "Editor failed to load",
+ "editor.nodeOrRouteNotFound": "Node or route not found",
+ "editor.backToNodes": "Back to nodes",
+ "editor.kernelshaperagenthintCurrentlyAgentversionP2": "{kernelShaperAgentHint} (currently {agentVersion}).{p2}",
+ "editor.acceptsProxy": "accepts PROXY",
+ "editor.pinnedByIp": "pinned by IP",
+ "editor.stickinessAlgorithmlabel": "stickiness · {algorithmLabel}",
+ "editor.pool": "pool",
+ "editor.primaryBackups": "primary + backups",
+ "editor.limitQuotavalueQuotaunit": "limit {quotaValue} {quotaUnit}",
+ "editor.speedP0MbitSP1": "speed {p0} Mbit/s{p1}",
+ "editor.noTrafficOrSpeedLimits": "No traffic or speed limits set",
+ "editor.saving": "Saving…",
+ "editor.notSaved": "Not saved",
+ "editor.unsavedChanges": "Unsaved changes",
+ "editor.noChanges": "No changes",
+ "editor.newRoute": "New route",
+ "editor.updateTemplate": "Update template",
+ "editor.templateSaved": "Template saved",
+ "editor.backToNode": "Back to node",
+ "editor.templateP0": "Template: {p0}",
+ "editor.status": "Status:",
+ "editor.enabled": "Enabled",
+ "editor.connection": "Connection",
+ "editor.routeName": "Route name",
+ "editor.visibleToOperatorsOnly": "Visible to operators only.",
+ "editor.routeSelection": "Route selection",
+ "editor.bySniThePortCan": "By SNI; the port can be shared.",
+ "editor.everythingThatDidNotMatch": "Everything that did not match an SNI.",
+ "editor.bySni": "By SNI",
+ "editor.listenOn": "Listen on",
+ "editor.allNodeAddresses": "All node addresses.",
+ "editor.aSingleNodeAddress": "A single node address.",
+ "editor.allIps": "All IPs",
+ "editor.singleIp": "Single IP",
+ "editor.allAddresses": "all addresses",
+ "editor.listenerIpAddress": "Listener IP address",
+ "editor.tcpOnTheNode": "TCP on the node.",
+ "editor.sniDomains": "SNI domains",
+ "editor.enterSpaceOrCommaNext": "Enter, space or comma — next domain.",
+ "editor.theFallbackReceivesConnectionsWith": "The fallback receives connections with no matching SNI.",
+ "editor.notNeededForAFallback": "not needed for a fallback",
+ "editor.acceptProxyProtocol": "Accept PROXY protocol",
+ "editor.aProxyInFrontOf": "A proxy in front of the node passes the client IP.",
+ "editor.trustedAddresses": "Trusted addresses",
+ "editor.ipsSubnetsOrDomainsDomain": "IPs, subnets or domains (domain IPs refresh automatically). Empty — expect PROXY from everyone.",
+ "editor.clientsWithoutProxyProtocolWill": "Clients without PROXY protocol will not be able to connect to this port — this applies to all routes on the port.",
+ "editor.servers2": "Servers",
+ "editor.addASecondServerOr": "Add a second server or a DNS pool to share the load.",
+ "editor.twoServersOrADns2": "Two servers or a DNS pool are required",
+ "editor.pool2": "Pool",
+ "editor.primaryBackups2": "Primary + backups",
+ "editor.howToDistributeTraffic": "How to distribute traffic",
+ "editor.serverConnection": "Server connection",
+ "editor.howTheNodeConnectsTo": "How the node connects to the servers.",
+ "editor.healthCheck": "Health check",
+ "editor.alwaysOnForADns": "Always on for a DNS pool.",
+ "editor.tcpEvery5SAn": "TCP every 5 s; an unreachable server is excluded.",
+ "editor.proxyProtocolToServer": "PROXY protocol to server",
+ "editor.passTheClientIpThe": "Pass the client IP; the server must accept PROXY.",
+ "editor.withoutProxySupportOnThe": "Without PROXY support on the server, connections will fail.{p0}",
+ "editor.no": "No",
+ "editor.slowStartAfterRecovery": "Slow start after recovery",
+ "editor.twoServersOrADns3": "Two servers or a DNS pool are required.",
+ "editor.requiresAHealthCheck": "Requires a health check.",
+ "editor.noEffectWeightsAreFixed": "No effect: weights are fixed.",
+ "editor.trafficToARecoveredServer": "Traffic to a recovered server ramps up gradually. Empty — immediately.",
+ "editor.s": "s",
+ "editor.advanced": "Advanced",
+ "editor.trafficLimit2": "Traffic limit",
+ "editor.routeTrafficCounterWithA": "Route traffic counter with a scheduled reset.",
+ "editor.limit": "Limit",
+ "editor.limitUnit": "Limit unit",
+ "editor.reset": "Reset",
+ "editor.whenReached": "When reached",
+ "editor.newConnectionsAreRefusedActive": "New connections are refused; active sessions are not dropped.",
+ "editor.thePanelWillHighlightThe": "The panel will highlight the overage; traffic keeps flowing.",
+ "editor.notifyOnly": "Notify only",
+ "editor.blockNewConnections": "Block new connections",
+ "editor.speedLimit": "Speed limit",
+ "editor.perClientIpAcrossAll": "Per client IP, across all of its connections in the route.",
+ "editor.fromClient": "From client",
+ "editor.emptyNoLimit": "Empty — no limit.",
+ "editor.toClient": "To client",
+ "editor.whereToLimit": "Where to limit",
+ "editor.nftablesOnTheNodeSplice": "nftables on the node, splice is preserved. Routes on the same port must have identical limits.",
+ "editor.exactPerRouteSniBut": "Exact per route (SNI), but disables splice on this port.",
+ "editor.kernelNftables": "Kernel (nftables)",
+ "editor.unknown": "unknown",
+ "editor.needsFixingLength": "Needs fixing: {length}",
+ "editor.needsFixing": "Needs fixing:",
+ "editor.saveTemplate": "Save template",
+ "editor.saveDraft": "Save draft",
+ "editor.saveAndApply": "Save and apply",
+ "editor.saveAndEnable": "Save and enable",
+ "editor.haproxyPreview": "HAProxy preview",
+ "editor.formIsValid": "Form is valid",
+ "editor.errorsLength": "Errors: {length}",
+ "editor.fillInTheRequiredFields": "Fill in the required fields",
+ "editor.dedicatedFrontend": "Dedicated frontend",
+ "editor.hasManualDirectives": "· has manual directives",
+ "editor.beforeApplyingTheAgentValidates": "Before applying, the Agent validates the config with",
+ "editor.ufwChangesOnlyAccordingTo": "; UFW changes only according to the node policy. Active connections are not dropped.",
+ "editor.haproxyPreviewReadOnly": "HAProxy preview, read-only",
+ "editor.readOnly": "read-only",
+ "editor.manualBackendDirectives": "Manual backend directives",
+ "editor.globalFrontendListenBackendAnd": "global, frontend, listen, backend and resolvers sections are not allowed.",
+ "editor.allowManualDirectives": "Allow manual directives",
+ "editor.backendDirectives": "Backend directives",
+ "editor.leaveWithoutSaving": "Leave without saving?",
+ "editor.routeChangesWillBeLost": "Route changes will be lost. HAProxy and UFW will not change.",
+ "editor.continue": "Continue",
+
+ // ── routeModel
+ "routeModel.byAlgorithm": "By algorithm",
+ "routeModel.eachNewConnectionGetsA": "Each new connection gets a server by the algorithm.",
+ "routeModel.pinByIp": "Pin by IP",
+ "routeModel.theServerIsDeterminedBy": "The server is determined by the client IP; no algorithm needed.",
+ "routeModel.rememberClient": "Remember client",
+ "routeModel.theAlgorithmPicksTheFirst": "The algorithm picks the first server, then the client stays on it.",
+ "routeModel.s": "s",
+ "routeModel.min": "min",
+ "routeModel.h": "h",
+ "routeModel.k": "K",
+ "routeModel.m": "M",
+ "routeModel.auto1000000Node": "Auto: 1,000,000, node RAM unknown",
+ "routeModel.autoFormatcountClients5Of": "Auto: ~{formatCount} clients (5% of node RAM), {tableMemoryEstimate} with a full table",
+ "routeModel.p0Gb": "≈{p0} GB",
+ "routeModel.roundMb": "≈{round} MB",
+ "routeModel.p0Mb": "≈{p0} MB",
+ "routeModel.roundRobin": "Round robin",
+ "routeModel.serversGetClientsInTurn": "Servers get clients in turn, weighted.",
+ "routeModel.random": "Random",
+ "routeModel.aRandomServerWeighted": "A random server, weighted.",
+ "routeModel.fewestConnections": "Fewest connections",
+ "routeModel.theServerWithTheFewest": "The server with the fewest connections right now.",
+ "routeModel.lowestLatency": "Lowest latency",
+ "routeModel.moreTrafficToServersWith": "More traffic to servers with lower latency (like leastLoad in Xray).",
+ "routeModel.hourly": "Hourly",
+ "routeModel.resetsAtTheStartOf": "Resets at the start of the next hour.",
+ "routeModel.daily": "Daily",
+ "routeModel.resetsAt0000Utc": "Resets at 00:00 UTC.",
+ "routeModel.calendarMonth": "Calendar month",
+ "routeModel.resetsOnThe1stAt": "Resets on the 1st at 00:00 UTC.",
+ "routeModel.monthFromCreation": "Month from creation",
+ "routeModel.theWindowStartsOnThe": "The window starts on the route creation date.",
+ "routeModel.expertLayerExceeds8192Bytes": "Expert layer exceeds 8192 bytes.",
+ "routeModel.expertLayerMustBeValid": "Expert layer must be valid UTF-8.",
+ "routeModel.expertLayerContainsAControl": "Expert layer contains a control character.",
+ "routeModel.lineContinuationAndEscapingDirectives": "Line continuation and escaping directives with \\\\ are not allowed.",
+ "routeModel.oneOfTheDirectivesIs": "One of the directives is longer than 512 bytes.",
+ "routeModel.theDirectiveSectionIsManaged": "The {directive} section is managed by NodeFlow and cannot be overridden.",
+ "routeModel.expertLayerExceeds8192Bytes2": "Expert layer exceeds 8192 bytes after normalization.",
+ "routeModel.withBackupsChooseAnHaproxy": "With backups, choose an HAProxy algorithm, e.g. “Fewest connections”.",
+ "routeModel.withBackupsToleranceMustBe": "With backups, tolerance must be 0 %.",
+ "routeModel.drawsMustBeFrom1": "Draws must be from 1 to 5.",
+ "routeModel.toleranceMustBeAnInteger": "Tolerance must be an integer from 0 to 100 %.",
+ "routeModel.toleranceInMsMustBe": "Tolerance in ms must be from 0 to 1000.",
+ "routeModel.overloadLimitMustBeFrom": "Overload limit must be from 101 to 1000 % or empty.",
+ "routeModel.ipv6PrefixMustBeFrom": "IPv6 prefix must be from /32 to /128.",
+ "routeModel.rememberForMustBeFrom": "“Remember for” must be from 1 minute to 168 hours.",
+ "routeModel.maxClientsMustBeFrom": "Max clients must be from 1 to {formatCount}.",
+ "routeModel.enterARouteNameIt": "Enter a route name. It is visible to operators only.",
+ "routeModel.routeNameMustNotExceed": "Route name must not exceed 80 characters.",
+ "routeModel.routeNameMustNotContain": "Route name must not contain tabs or control characters.",
+ "routeModel.listenerAddressMustBeOr": "Listener address must be * or a valid IP.",
+ "routeModel.listenerPortMustBeFrom": "Listener port must be from 1 to 65535.",
+ "routeModel.addAtLeastOneSni": "Add at least one SNI.",
+ "routeModel.noMoreThan64Snis": "No more than 64 SNIs allowed.",
+ "routeModel.eachSniMustBeA": "Each SNI must be a valid DNS name.",
+ "routeModel.snisWithinARouteMust": "SNIs within a route must not repeat.",
+ "routeModel.theListMustNotExceed": "The list must not exceed 256 entries.",
+ "routeModel.invalidAddressSubnetOrDomain": "Invalid address, subnet or domain: {entry}",
+ "routeModel.noMoreThanMaxAccept": "No more than {MAX_ACCEPT_PROXY_DOMAINS} domains in the list.",
+ "routeModel.addAtLeastOneDestination": "Add at least one destination server.",
+ "routeModel.noMoreThan16Servers": "No more than 16 servers.",
+ "routeModel.serverNameTrimMustNot": "Server name {trim} must not end with _pref.",
+ "routeModel.serverNamesMustNotRepeat": "Server names must not repeat.",
+ "routeModel.serverP0EnterAName": "Server {p0}: enter a name.",
+ "routeModel.serverP0NameLettersDigits": "Server {p0}: name — letters, digits, _, - (up to {SERVER_NAME_MAX} characters).",
+ "routeModel.serverP0EnterAnAddress": "Server {p0}: enter an address.",
+ "routeModel.serverP0HostIsNot": "Server {p0}: {host} is not a valid IPv4 address.",
+ "routeModel.serverP0AddressMustBe": "Server {p0}: address must be an IP or a domain.",
+ "routeModel.serverP0InvalidPort": "Server {p0}: invalid port.",
+ "routeModel.serverP0DnsPoolIs": "Server {p0}: DNS pool is not available for IP addresses.",
+ "routeModel.serverP0PreferredIpIs": "Server {p0}: preferred IP is not a valid address.",
+ "routeModel.serverP0InvalidUnixSocket": "Server {p0}: invalid Unix socket path.",
+ "routeModel.serverP0WeightMustBe": "Server {p0}: weight must be an integer from 1 to 256.",
+ "routeModel.serverP0CostMustBe": "Server {p0}: cost must be a number greater than 0 and at most 100.",
+ "routeModel.serverP0PerIpWeight": "Server {p0}: per-IP weight and cost are available only for a DNS pool.",
+ "routeModel.serverP0NoMoreThan": "Server {p0}: no more than 32 per-IP entries.",
+ "routeModel.serverP0IpsMustNot": "Server {p0}: IPs must not repeat.",
+ "routeModel.serverP0InvalidIpIn": "Server {p0}: invalid IP in the per-IP list.",
+ "routeModel.serverP0PerIpWeight2": "Server {p0}: per-IP weight must be an integer from 1 to 256.",
+ "routeModel.serverP0PerIpCost": "Server {p0}: per-IP cost must be a number from 0.01 to 100.",
+ "routeModel.serverP0PerIpWeight3": "Server {p0}: per-IP weight does not work with fixed weights — turn on “Dynamic weights” or “Stable” distribution.",
+ "routeModel.markAtLeastOneServer": "Mark at least one server as “Primary”.",
+ "routeModel.markAtLeastOneServer2": "Mark at least one server as “Backup” or choose “Pool” mode.",
+ "routeModel.withAPreferredIpThe": "With a preferred IP the other domain IPs already act as backups — remove the preferred IP or the backup servers.",
+ "routeModel.aBackupServerWithA": "A backup server with a DNS pool must be the only backup.",
+ "routeModel.serverNameTrimClashesWith": "Server name {trim} clashes with the DNS pool address names {p1}.",
+ "routeModel.theLimitMustBeA": "The limit must be a positive number within the safe range.",
+ "routeModel.blockingNewConnectionsRequiresThe": "Blocking new connections requires the limit to be enabled.",
+ "routeModel.blockingNewConnectionsIsAvailable": "Blocking new connections is available only for a single server without a DNS pool. Choose “Notify only”.",
+ "routeModel.uploadLimit": "Upload limit",
+ "routeModel.downloadLimit": "Download limit",
+ "routeModel.labelMustBeAnInteger": "{label} must be an integer from 1 to 1,000,000 Mbps or empty.",
+ "routeModel.slowStartRequiresTheHealth": "Slow start requires the health check to be enabled.",
+ "routeModel.slowStartMustBeFrom": "Slow start must be from 1 to 600 seconds.",
+ "routeModel.withADnsPoolThe": "With a DNS pool the expert layer cannot set balance, hash-type, server, server-template or default-server.",
+ "routeModel.portListenerportIsAlreadyListened": "Port {listenerPort} is already listened on by {p1}. Wildcard and a specific IP cannot be mixed.",
+ "routeModel.listeneripListenerportAlreadyHasA": "{listenerIP}:{listenerPort} already has a fallback route.",
+ "routeModel.sniDuplicateIsAlreadyAssigned": "SNI {duplicate} is already assigned to another route on {listenerIP}:{listenerPort}.",
+ "routeModel.onListeneripListenerportRoutePeername": "On {listenerIP}:{listenerPort} route {peerName} limits speed via {p3}; modes cannot be mixed on one listener.",
+ "routeModel.kernelLimitsOnListeneripListenerport": "Kernel limits on {listenerIP}:{listenerPort} must match route {peerName} (↑{p3} / ↓{p4} Mbps).",
+ "routeModel.otherNameserversAndHoldParameters": "# … other nameservers and hold parameters",
+
+ // ── templates
+ "templates.copiesAreCreatedDisabledCounters": "Copies are created disabled; counters and history are not carried over.",
+ "templates.onAnotherNodeCheckThe": "On another node, check the listener IP and Unix sockets before enabling.",
+ "templates.targetNodes": "Target nodes",
+ "templates.selectNodes": "Select nodes",
+ "templates.nameThisNode": "{name} (this node)",
+ "templates.resultPerNode": "Result per node",
+ "templates.draftCreated": "Draft created",
+ "templates.open": "Open",
+ "templates.nodeListUnavailable": "Node list unavailable",
+ "templates.copiesNotCreated": "Copies not created",
+ "templates.copy": "Copy",
+ "templates.templateNotSaved": "Template not saved",
+ "templates.changingATemplateDoesNot": "Changing a template does not affect routes created from it.",
+ "templates.name": "Name",
+ "templates.description": "Description",
+ "templates.optional": "Optional",
+ "templates.fallback": "fallback",
+ "templates.templatesUnavailable": "Templates unavailable",
+ "templates.routesNotCreated": "Routes not created",
+ "templates.templateNotDeleted": "Template not deleted",
+ "templates.loading": "Loading…",
+ "templates.noTemplatesSaveARoute": "No templates. Save a route as a template from the route menu.",
+ "templates.templates": "Templates",
+ "templates.configureForThisNode": "Configure for this node",
+ "templates.editTemplate": "Edit template",
+ "templates.createRoutes": "Create routes",
+ "templates.deleteTemplate": "Delete template?",
+ "templates.template": "Template",
+ "templates.willBeDeletedRoutesCreated": "will be deleted. Routes created from it will not change.",
+ "templates.templateNotFound": "Template not found",
+ "templates.nameCopy": "{name} (copy)",
+
+ // ── plural / hand-written
+ "editor.sniDomainsCount": { one: "SNI · {count} domain", other: "SNI · {count} domains" },
+ "editor.serversCount": { one: "{count} server", other: "{count} servers" },
+ "editor.sharedFrontendWith": { one: "Shared frontend with {count} route", other: "Shared frontend with {count} routes" },
+ "app.language": "Language",
+ "app.languageRu": "Русский",
+ "app.languageEn": "English",
+ "editor.clientIpFromProxy": "The client IP is taken from PROXY protocol when present, otherwise from the connection address.",
+ "nodes.routesEnabled": "{count} enabled",
+ "nodes.showingNodes": { one: "Showing {from}–{to} of {count} node", other: "Showing {from}–{to} of {count} nodes" },
+ "node.showingRoutes": { one: "Showing {from}–{to} of {count} route", other: "Showing {from}–{to} of {count} routes" },
+ "app.perPage": "{size} / page",
+ "node.newPerSecond": "{rate} new/s",
+ "node.totalCount": "{total} total",
+ "node.rateNotReceived": "Rate not received",
+ "node.totalNotReceivedLower": "total not received",
+ "editor.perIpWeightsAreApplied": "Per-IP weights are applied by the node agent — update Node Agent to {version} or later.",
+ "editor.kernelNodeAgentRequired": "Kernel: requires Node Agent {version}, the node has {current}",
+ // ── api error codes (backend `error.code`)
+ "apiError.not_found": "Not found.",
+ "apiError.internal_error": "Internal panel error. See the server logs for details.",
+ "apiError.unauthorized": "Session expired or invalid token. Sign in again.",
+ "apiError.invalid_origin": "The request must come from the panel's own origin.",
+ "apiError.already_exists": "This item already exists.",
+ "apiError.stale_route_version": "The route was changed by another operation. Reload and try again.",
+ "apiError.stale_order": "The list has changed. Reload and try again.",
+ "apiError.stale_haproxy_control": "HAProxy state has changed. Reload and try again.",
+ "apiError.haproxy_control_unsupported": "Update Node Agent to control HAProxy.",
+ "apiError.haproxy_disabled": "Start HAProxy before restarting it.",
+ "apiError.advanced_config_active": "Manual HAProxy configuration is active. Switch back to the generated configuration to change routes.",
+ "apiError.database_unavailable": "The database is unavailable.",
+ "apiError.route_copy_failed": "Failed to create a draft on the node",
+ "apiError.node_not_found": "Node not found",
+ "apiError.release_in_use": "The release is installed or assigned to a node.",
+ "apiError.bootstrap_job_limit": "Too many install jobs. Wait for an active one to finish.",
+ "apiError.rate_limited": "Too many concurrent DNS lookups. Try again later.",
+ "apiError.invalid_host": "Enter a DNS hostname, not an IP address.",
+};
diff --git a/frontend/src/locales/ru.ts b/frontend/src/locales/ru.ts
new file mode 100644
index 0000000..8999257
--- /dev/null
+++ b/frontend/src/locales/ru.ts
@@ -0,0 +1,812 @@
+/**
+ * Russian UI strings — the source of truth for message keys.
+ * Values: plain string with `{name}` placeholders, or plural forms selected by
+ * the `count` param via Intl.PluralRules (ru: one/few/many/other).
+ */
+export type Message = string | Partial>;
+
+export const ru = {
+ // ── app
+ "app.loadingInterface": "Загрузка интерфейса",
+ "app.loadingNode": "Загрузка ноды",
+ "app.loadingConfigurationEditor": "Загрузка редактора конфигурации",
+ "app.loadingRouteEditor": "Загрузка редактора маршрута",
+ "app.loadingTrafficStatistics": "Загрузка статистики трафика",
+ "app.loadingSettings": "Загрузка настроек",
+ "app.nodes": "Ноды",
+ "app.traffic": "Трафик",
+ "app.settings": "Настройки",
+ "app.checkingSession": "Проверка сессии",
+ "app.closeMenu": "Закрыть меню",
+ "app.openMenu": "Открыть меню",
+ "app.mainNavigation": "Основная навигация",
+ "app.operator": "Оператор",
+ "app.localOperator": "Локальный оператор",
+ "app.signOut": "Выйти",
+ "app.signInFailed": "Не удалось войти",
+ "app.signInToThePanel": "Вход в панель",
+ "app.useTheAdminTokenAfter": "Используйте административный токен. После входа он заменяется защищённой browser-сессией.",
+ "app.panelToken": "Токен панели",
+ "app.signIn": "Войти",
+ "app.breadcrumbs": "Хлебные крошки",
+ "app.retry": "Повторить",
+ "app.panelApiIsUnreachableCheck": "Panel API недоступен. Проверьте соединение.",
+
+ // ── format
+ "format.b": "Б",
+ "format.kib": "КиБ",
+ "format.mib": "МиБ",
+ "format.gib": "ГиБ",
+ "format.tib": "ТиБ",
+ "format.pib": "ПиБ",
+ "format.bitS": "бит/с",
+ "format.kbitS": "Кбит/с",
+ "format.mbitS": "Мбит/с",
+ "format.gbitS": "Гбит/с",
+ "format.tbitS": "Тбит/с",
+ "format.currentMonth": "текущий месяц",
+ "format.never": "никогда",
+ "format.floorSAgo": "{floor} сек назад",
+ "format.p0MinAgo": "{p0} мин назад",
+ "format.p0HAgo": "{p0} ч назад",
+ "format.p0DAgo": "{p0} д назад",
+ "format.daysDHoursHMinutes": "{days} д. {hours} ч. {minutes} м.",
+ "format.hoursHMinutesM": "{hours} ч. {minutes} м.",
+ "format.minutesMin": "{minutes} мин.",
+
+ // ── nodes
+ "nodes.dataIsStale": "Данные устарели",
+ "nodes.noFreshData": "Нет свежих данных",
+ "nodes.currentDataIsIncomplete": "Текущие данные неполные",
+ "nodes.nodeIsUnreachable": "Нода недоступна",
+ "nodes.nodeIsDegraded": "Нода деградирует",
+ "nodes.online": "Онлайн",
+ "nodes.nodesAreUnreachable": "Ноды недоступны",
+ "nodes.partiallyAvailable": "Частично доступно",
+ "nodes.nodeOrderNotSaved": "Порядок нод не сохранён",
+ "nodes.searchNodes": "Поиск нод...",
+ "nodes.searchNodes2": "Поиск нод",
+ "nodes.refresh": "Обновить",
+ "nodes.addNode": "Добавить ноду",
+ "nodes.sourceReset": "Источник сброшен",
+ "nodes.dismissNotice": "Скрыть уведомление",
+ "nodes.theSelectedNodeIsNo": "Выбранная нода больше недоступна. Показана сводка всех нод.",
+ "nodes.failedToLoadNodes": "Не удалось загрузить ноды",
+ "nodes.panelApiIsUnreachable": "Panel API недоступен",
+ "nodes.noNodesYet": "Нод пока нет",
+ "nodes.addAServerOverSsh": "Добавьте сервер по SSH — панель установит Node Agent и HAProxy.",
+ "nodes.allNodes": "Все ноды",
+ "nodes.trafficSource": "Источник трафика",
+ "nodes.statusFilter": "Фильтр состояния",
+ "nodes.online2": "Работают",
+ "nodes.degraded": "Деградируют",
+ "nodes.unreachable": "Недоступны",
+ "nodes.searchInTable": "Поиск в таблице",
+ "nodes.toggleRowDensity": "Переключить плотность строк",
+ "nodes.close": "Закрыть",
+ "nodes.clearSearchAndFiltersTo": "Сбросьте поиск и фильтры, чтобы менять порядок.",
+ "nodes.reorderingIsOnlyAvailableOn": "Порядок можно менять на первой странице.",
+ "nodes.nothingFound": "Ничего не найдено",
+ "nodes.changeTheSearchOrStatus": "Измените поиск или фильтр состояния.",
+ "nodes.filters": "Фильтры",
+ "nodes.nodeStatus": "Состояние ноды",
+ "nodes.online3": "Работает",
+ "nodes.degraded2": "Деградирует",
+ "nodes.unreachable2": "Недоступна",
+ "nodes.openNodeName": "Открыть ноду {name}",
+ "nodes.dragNode": "Перетащить ноду",
+ "nodes.dragNodeName": "Перетащить ноду {name}",
+ "nodes.moveNameUp": "Переместить {name} выше",
+ "nodes.moveNameDown": "Переместить {name} ниже",
+ "nodes.seen": "ответ",
+ "nodes.noRate": "нет rate",
+ "nodes.connectionRateNewS": "{connection_rate} новых/с",
+ "nodes.noLimit": "без лимита",
+ "nodes.roundPercentOfQuotaUsed": "Использовано {round} процентов квоты",
+ "nodes.trafficWithoutLimit": "Трафик без лимита",
+ "nodes.noData": "нет данных",
+ "nodes.actionsName": "Действия: {name}",
+ "nodes.openNode": "Открыть ноду",
+ "nodes.addRoute": "Добавить маршрут",
+ "nodes.nodeManagement": "Управление нодой",
+ "nodes.status": "Статус",
+ "nodes.node": "Нода",
+ "nodes.routes": "Маршруты",
+ "nodes.connections": "Соединения",
+ "nodes.monthlyTraffic": "Трафик за месяц",
+ "nodes.actions": "Действия",
+ "nodes.showing": "Показано",
+ "nodes.totalThroughput": "Общая скорость",
+ "nodes.incompleteData": "Неполные данные",
+ "nodes.rxTotal": "RX суммарно",
+ "nodes.txTotal": "TX суммарно",
+ "nodes.ofFormatbytes": "из {formatBytes}",
+ "nodes.noSharedLimit": "без общего лимита",
+ "nodes.roundUsed": "{round}% использовано",
+ "nodes.noSharedLimitSet": "Общий лимит не задан",
+ "nodes.noQuotaSet": "Квота не задана",
+ "nodes.noCompleteCurrentSample": "нет полной текущей точки",
+ "nodes.activeCurrentSnapshot": "активных · текущий срез",
+ "nodes.unavailable": "Недоступно",
+ "nodes.allTrafficP0ListenerPort": "Весь трафик · {p0}:{listener_port}",
+ "nodes.routeP0ListenerPort": "Маршрут · {p0}:{listener_port}",
+ "nodes.topRoutes": "Топ маршрутов",
+ "nodes.route": "Маршрут",
+ "nodes.thisMonthUsedBytes": "За месяц: {used_bytes}",
+ "nodes.noRoutesYet": "Маршрутов пока нет",
+ "nodes.viewAllRoutes": "Посмотреть все маршруты",
+ "nodes.n1m": "1м",
+ "nodes.n5m": "5м",
+ "nodes.n1h": "1ч",
+ "nodes.n24h": "24ч",
+ "nodes.n7d": "7д",
+ "nodes.n30d": "30д",
+ "nodes.collectingTrafficHistory": "История трафика собирается",
+ "nodes.theFirstSampleWillAppear": "Первая точка появится после сигнала Node Agent.",
+ "nodes.waitingForTheFirstSample": "Ожидаем первую точку",
+ "nodes.updatedP0": "Обновлено: {p0}",
+ "nodes.now": "Сейчас",
+ "nodes.latestSample": "Последняя точка",
+ "nodes.trafficForP0ValuescopeRx": "Трафик за период {p0}. {valueScope} RX {formatBitrate}, TX {p3}.",
+ "nodes.historicalData": "Исторические данные",
+ "nodes.currentThroughput": "Текущая скорость",
+ "nodes.latestHistorySample": "Последняя точка истории",
+ "nodes.chartPeriod": "Период графика",
+ "nodes.unnamed": "Без имени",
+
+ // ── node
+ "node.needsAttention": "Требует внимания",
+ "node.haproxyIsStopped": "HAProxy выключен",
+ "node.nodeFailedToLoad": "Нода не загрузилась",
+ "node.nodeNotFound": "Нода не найдена",
+ "node.backToNodes": "К списку нод",
+ "node.noCurrentSample": "Нет текущей точки",
+ "node.nodeNotDeleted": "Нода не удалена",
+ "node.enterTheNodeNameAnd": "Укажите название и IP-адрес ноды.",
+ "node.fixTheHaproxySettings": "Исправьте параметры HAProxy.",
+ "node.nodeSettingsNotSaved": "Параметры ноды не сохранены",
+ "node.routeNotUpdated": "Маршрут не изменён",
+ "node.routeNotDeleted": "Маршрут не удалён",
+ "node.routeOrderNotSaved": "Порядок маршрутов не сохранён",
+ "node.haproxyNotP0": "HAProxy не {p0}",
+ "node.haproxyNotRestarted": "HAProxy не перезагружен",
+ "node.ufwModeNotChanged": "Режим UFW не изменён",
+ "node.nodeAgentStatusIsUnavailable": "Состояние Node Agent недоступно. Обновите данные и повторите.",
+ "node.releaseNotScheduled": "Релиз не назначен",
+ "node.rollbackNotScheduled": "Откат не назначен",
+ "node.lastHeartbeat": "Последний сигнал:",
+ "node.routeTemplates": "Шаблоны маршрутов",
+ "node.haproxyConfiguration": "Конфигурация HAProxy",
+ "node.nodeSettings": "Настройки ноды",
+ "node.nodeActions": "Действия ноды",
+ "node.refreshData": "Обновить данные",
+ "node.editNode": "Редактировать ноду",
+ "node.reinstallNodeAgent": "Переустановить Node Agent",
+ "node.hardRestartHaproxy": "Жёстко перезагрузить HAProxy",
+ "node.stopHaproxy": "Выключить HAProxy",
+ "node.startHaproxy": "Включить HAProxy",
+ "node.deleteNode": "Удалить ноду",
+ "node.nodeIsNotResponding": "Нода не отвечает.",
+ "node.nodeIsRunningWithLimitations": "Нода работает с ограничениями.",
+ "node.showingTheLastSavedValues": "Показаны последние сохранённые значения; управляющие действия могут ждать восстановления связи.",
+ "node.checkTheHaproxyRuntimeBackend": "Проверьте HAProxy runtime, backend health и состояние применения маршрутов.",
+ "node.haproxyIsStopped2": "HAProxy выключен.",
+ "node.nodeAgentStaysConnectedBut": "Node Agent остаётся на связи, но listener-порты и все маршруты остановлены.",
+ "node.haproxyControlIsUnavailable": "Управление HAProxy недоступно.",
+ "node.updateNodeAgentToA": "Обновите Node Agent до версии с поддержкой включения и выключения сервиса.",
+ "node.systemStatisticsAreTemporarilyUnavailable": "Системная статистика временно недоступна:",
+ "node.nodeTraffic": "Трафик ноды",
+ "node.historyUnavailableHistory": "История недоступна: {history}",
+ "node.newRangeNotLoadedP0": "Новый диапазон не загружен: {p0}",
+ "node.updatingTheRangeWithoutReloading": "Обновляем диапазон без перезагрузки страницы…",
+ "node.stopHaproxy2": "Выключить HAProxy?",
+ "node.allRoutesOnThisNode": "Все маршруты этой ноды остановятся.",
+ "node.nodeAgentWillStayConnected": "Node Agent останется на связи. HAProxy можно будет снова включить из этого же меню.",
+ "node.lastError": "Последняя ошибка:",
+ "node.cancel": "Отмена",
+ "node.hardRestartHaproxy2": "Жёстко перезагрузить HAProxy?",
+ "node.allActiveConnectionsWillBe": "Все активные соединения будут немедленно оборваны.",
+ "node.thisIsASystemdRestart": "Это именно systemd restart, а не безопасный reload. Клиентам потребуется переподключиться.",
+ "node.hardRestart": "Жёстко перезагрузить",
+ "node.nodeName": "Название ноды",
+ "node.nodeIpAddress": "IP-адрес ноды",
+ "node.haproxyConnectionLogs": "Логи соединений HAProxy",
+ "node.everyConnectionIsWrittenTo": "Каждое соединение пишется в syslog. На маленьких дисках выключите.",
+ "node.auto": "авто",
+ "node.emptyDefaultValueAppliedTo": "Пусто = значение по умолчанию. Применяется ко всем маршрутам ноды мягким reload.",
+ "node.reinstallNodeAgent2": "Переустановка Node Agent",
+ "node.nodeflowWillReVerifyThe": "NodeFlow повторно проверит отпечаток SSH-ключа хоста, установит Agent и дождётся нового mTLS-сигнала. ID ноды и маршруты не меняются; при ошибке прежние учётные данные будут автоматически восстановлены.",
+ "node.reinstallAgent": "Переустановить Agent",
+ "node.save": "Сохранить",
+ "node.deleteNode2": "Удалить ноду?",
+ "node.theRecord": "Запись",
+ "node.andRelatedDataWillBe": "и связанные данные будут удалены из Panel. Node Agent и HAProxy на сервере останутся установленными.",
+ "node.toConfirmType": "Для подтверждения введите",
+ "node.labelHistoryIsStillBeing": "{label}: история ещё собирается",
+ "node.deltaUnavailable": "дельта недоступна",
+ "node.deltaP0Formatter": "дельта {p0}{formatter}",
+ "node.labelAverageFormatterDeltacopySamples": "{label}: среднее {formatter}, {deltaCopy}, точек {sampleCount}",
+ "node.processesFormatnumberP1": "Процессов: {formatNumber}{p1}",
+ "node.mainProcessesP0": "Основные процессы: {p0}",
+ "node.processListNotReceivedYet": "Список процессов ещё не получен",
+ "node.systemNetworkAndConnections": "Система, сеть и соединения",
+ "node.noData": "Нет данных",
+ "node.p0Used": "{p0}% занято",
+ "node.normalizedLinuxLoadAverageOver": "Нормализованные значения Linux load average за 1, 5 и 15 минут",
+ "node.load1m5m15m": "Load (1м / 5м / 15м)",
+ "node.currentSnapshot": "текущий срез",
+ "node.latestSampleTimeago": "последняя точка {timeAgo}",
+ "node.noFreshSample": "нет свежей точки",
+ "node.haproxyNow": "HAProxy сейчас",
+ "node.haproxyRuntimeNotReceivedYet": "HAProxy runtime ещё не получен",
+ "node.rateAndTotalNotReceived": "Rate и total не получены",
+ "node.formatnumberTcpSessionsTotal": "{formatNumber} TCP-сессий всего",
+ "node.tcpSessions": "TCP-сессии",
+ "node.totalNotReceived": "Total не получен",
+ "node.formatnumberTotal": "{formatNumber} всего",
+ "node.uptime": "Время работы",
+ "node.formatnumberProcesses": "{formatNumber} процессов",
+ "node.withoutAgentRestart": "без перезапуска Agent",
+ "node.mtlsConnection": "mTLS соединение",
+ "node.connected": "На связи",
+ "node.noFreshHeartbeat": "Нет свежего сигнала",
+ "node.lastHeartbeatOverTheSecure": "последний heartbeat по защищённому каналу",
+ "node.routeCreated": "Создан маршрут",
+ "node.routeChanged": "Изменён маршрут",
+ "node.routeEnabled": "Маршрут включён",
+ "node.routeDisabled": "Маршрут выключен",
+ "node.routeDeleted": "Удалён маршрут",
+ "node.nodeAgentUpdateScheduled": "Назначено обновление Node Agent",
+ "node.nodeAgentRollbackScheduled": "Назначен откат Node Agent",
+ "node.ufwPolicyChanged": "Изменена политика UFW",
+ "node.nodeSettingsChanged": "Изменены параметры ноды",
+ "node.installed": "Установлено",
+ "node.ready": "Готов",
+ "node.scheduled": "Назначено",
+ "node.downloading": "Загрузка",
+ "node.verifying": "Проверка",
+ "node.installing": "Установка",
+ "node.error": "Ошибка",
+ "node.autoRollback": "Автооткат",
+ "node.nodePlatformIsUnknownOr": "Платформа ноды неизвестна или изменилась. Обновите состояние перед откатом.",
+ "node.updatesAndFirewall": "Обновление и firewall",
+ "node.platformlabelVersionAvailable": "{platformLabel} · доступна {version}",
+ "node.platformlabelUpToDate": "{platformLabel} · обновлений нет",
+ "node.platformNotReceived": "платформа не получена",
+ "node.selectVersion": "Выберите версию",
+ "node.rollBack": "Откатить",
+ "node.releaseSchedulingIsBlockedThe": "Назначение релиза заблокировано: Agent ещё не передал os и arch.",
+ "node.ufwPolicy": "Политика UFW",
+ "node.autoOpenAllowed": "Автооткрытие разрешено",
+ "node.checkOnly": "Только проверка",
+ "node.notManaged": "Не управляется",
+ "node.plannedPorts": "Порты по плану",
+ "node.noneActive": "нет активных",
+ "node.off": "Выкл.",
+ "node.auto2": "Авто",
+ "node.ufwManagementPolicy": "Политика управления UFW",
+ "node.advancedNodeSettings": "Дополнительные настройки ноды",
+ "node.recentActions": "Последние действия",
+ "node.recentActionsScrollableList": "Последние действия, прокручиваемый список",
+ "node.succeeded": "Успешно",
+ "node.noActionsYet": "Действий пока нет.",
+ "node.versionsAndConnection": "Версии и соединение",
+ "node.fullVersionNotReceived": "Полная версия не получена",
+ "node.panelConnection": "Связь с Panel",
+ "node.outboundMtls": "Исходящий mTLS · :",
+ "node.lastAuthorization": "Последняя авторизация",
+ "node.updatePolicy": "Политика обновления",
+ "node.ed25519AutoRollback": "Ed25519 · автооткат",
+ "node.rollBackNodeAgent": "Откатить Node Agent?",
+ "node.theSignedRelease": "Будет назначен подписанный релиз",
+ "node.for": "для",
+ "node.willBeScheduledTheUpdater": ". Updater проверит Ed25519, SHA-256 и вернёт текущий бинарник при ошибке запуска.",
+ "node.active": "Активен",
+ "node.applying": "Применяется",
+ "node.draft": "Черновик",
+ "node.disabled": "Выключен",
+ "node.deleting": "Удаляется",
+ "node.fallback": "Фолбэк",
+ "node.dnsPoolTarget": "DNS-пул · {target}",
+ "node.haproxyBackendP0ServersHealthy": "HAProxy backend: {p0} · серверов {healthy}/{length}",
+ "node.backendNotObservedYet": "Backend ещё не наблюдался",
+ "node.serverMapNotReceived": "Карта серверов не получена",
+ "node.haproxyBackendLabelServerMap": "HAProxy backend: {label} · карта серверов не получена",
+ "node.editRouteName": "Редактировать маршрут {name}",
+ "node.p0RouteName": "{p0} маршрут {name}",
+ "node.dragRoute": "Перетащить маршрут",
+ "node.dragRouteName": "Перетащить маршрут {name}",
+ "node.routeActionsName": "Действия маршрута {name}",
+ "node.statistics": "Статистика",
+ "node.edit": "Редактировать",
+ "node.duplicateRoute": "Дублировать маршрут",
+ "node.copyToNodes": "Копировать на ноды",
+ "node.saveAsTemplate": "Сохранить как шаблон",
+ "node.delete": "Удалить",
+ "node.waitForTheCurrentChange": "Дождитесь завершения текущего изменения.",
+ "node.routeHistoryUnavailable": "История маршрута недоступна",
+ "node.routeFilter": "Фильтр маршрутов",
+ "node.all": "Все",
+ "node.enabled": "Включены",
+ "node.drafts": "Черновики",
+ "node.searchRoutes": "Поиск маршрутов...",
+ "node.searchRoutes2": "Поиск маршрутов",
+ "node.status": "Состояние",
+ "node.allRoutes": "Все маршруты",
+ "node.enabledOnly": "Только включённые",
+ "node.draftsOnly": "Только черновики",
+ "node.failedFailed": "С ошибкой ({failed})",
+ "node.nodeRoutes": "Маршруты ноды",
+ "node.listensOn": "Слушает",
+ "node.matchRule": "Правило соответствия",
+ "node.destination": "Назначение",
+ "node.backends": "Бэкенды",
+ "node.unmatchedConnectionsAreDropped": "несовпавшие соединения сбрасываются",
+ "node.createAListenerTargetA": "Создайте listener → target. Новый маршрут сохранится выключенным черновиком.",
+ "node.deleteRoute": "Удалить маршрут?",
+ "node.willFirstBeSafelyRemoved": "будет сначала безопасно исключён из HAProxy, затем удалён после подтверждения Agent.",
+ "node.statisticsRoutename": "Статистика · {routeName}",
+ "node.routeStatistics": "Статистика маршрута",
+ "node.loadingHistory": "Загружаем историю…",
+ "node.historyUnavailable": "История недоступна",
+ "node.noSamplesForTheSelected": "Нет точек за выбранный период",
+ "node.theNewSampleSetWill": "Новая выборка заменит предыдущий период.",
+ "node.retryTheRequestOrPick": "Повторите запрос или выберите другой период.",
+ "node.haproxyReportedNoSamplesFor": "HAProxy не передал точки для этого маршрута и диапазона.",
+ "node.requestInProgress": "Запрос выполняется",
+ "node.requestFailed": "Запрос завершился ошибкой",
+ "node.noSamples": "Точек нет",
+ "node.loadError": "Ошибка загрузки",
+ "node.dataUnavailable": "Данные недоступны",
+ "node.integer1P0": "Целое число 1–{p0}",
+ "node.format5s15m": "Формат: 5s, 15m",
+
+ // ── misc
+ "misc.dataUnavailable": "данные недоступны",
+ "misc.started": "включён",
+ "misc.stopped": "выключен",
+ "misc.total": "— всего",
+ "misc.newS": "— новых/с",
+ "misc.formatnumberNewS": "{formatNumber} новых/с",
+ "misc.totalNotReceived": "total не получен",
+ "misc.rateNotReceived": "Rate не получен",
+ "misc.mainP0": ". Основные: {p0}",
+ "misc.disable": "Выключить",
+ "misc.enable": "Включить",
+ "misc.theHealthCheckSendsThe": "Проверка тоже шлёт заголовок.",
+ "misc.kernel": "· ядро",
+ "misc.chooseHaproxy": "Выберите HAProxy.",
+ "misc.theKernel": "ядро",
+
+ // ── editor
+ "editor.theKernelShaperRequiresNode": "Ядерный шейпер требует Node Agent {KERNEL_SHAPER_MIN_AGENT} на этой ноде",
+ "editor.name": "имя",
+ "editor.port": "порт",
+ "editor.nodeIp": "IP ноды",
+ "editor.trafficLimit": "лимит трафика",
+ "editor.speed": "скорость",
+ "editor.serverAddress": "адрес сервера",
+ "editor.servers": "серверы",
+ "editor.checkTheFormFields": "Проверьте поля формы",
+ "editor.p0AndP1": "{p0} и {p1}",
+ "editor.fillInList": "Заполните {list}",
+ "editor.draftSaved": "Черновик сохранён",
+ "editor.routeCreatedAsADisabled": "Маршрут создан как выключенный черновик",
+ "editor.changesSentToTheNode": "Изменения отправлены на ноду",
+ "editor.routeCreatedAndSentFor": "Маршрут создан и отправлен на включение",
+ "editor.theDomainDidNotResolve": "Домен не вернул ни одного адреса.",
+ "editor.invalidDomainName": "Некорректное имя домена.",
+ "editor.failedToResolveAddresses": "Не удалось получить адреса.",
+ "editor.firstEnterADomainIn": "Сначала укажите домен в поле «Адрес»",
+ "editor.domainIps": "IP домена",
+ "editor.preferredIp": "Основной IP",
+ "editor.allTrafficGoesToThis": "Весь трафик на этот IP; если он недоступен — на все IP домена и резервные серверы.",
+ "editor.allTrafficGoesToThis2": "Весь трафик на этот IP; если он недоступен — на все IP домена.",
+ "editor.notSet": "не задан",
+ "editor.invalidIpAddress": "Некорректный IP-адрес.",
+ "editor.ipAddressesOfHost": "IP-адреса {host}",
+ "editor.addressesWereResolvedOnThe": "Адреса получены с хоста панели — нода может видеть другие.",
+ "editor.shareOfNewClientsFor": "Доля новых клиентов для этого IP: 2 — вдвое больше, чем у IP с весом 1. Пусто — вес сервера (по умолчанию 1).",
+ "editor.latencyMultiplierForThisIp": "Множитель задержки этого IP: 2 — IP кажется вдвое медленнее и выбирается реже. Пусто — стоимость сервера (по умолчанию 1).",
+ "editor.newClientsAreSplitIn": "Новые клиенты делятся пропорционально весу: 4 ядра против 2 — вес 2 у 4-ядерного, и он получит вдвое больше клиентов. При «Запоминании» уже закреплённые клиенты остаются на своём IP.",
+ "editor.theLowerLatencyCostThe": "Чем меньше задержка × стоимость, тем больше клиентов получает IP; вес задаёт долю при равной задержке. Под мощность сервера: 4 ядра против 2 — вес 2 у 4-ядерного.",
+ "editor.anIpSShareIs": "Доля IP пропорциональна весу. Например, 4 ядра против 2: вес 2 у 4-ядерного — он получит вдвое больше клиентов.",
+ "editor.perIpWeightAndCost": "Вес и стоимость отдельных IP",
+ "editor.perIpWeight": "Вес отдельных IP",
+ "editor.emptySameAsTheServer": "Пусто — как у сервера.",
+ "editor.weightsAreCurrentlyFixedAnd": "Сейчас веса фиксированы и не применятся.",
+ "editor.perIpSettingsLabel": "Отдельные IP {label}",
+ "editor.weight": "Вес",
+ "editor.cost": "Стоимость",
+ "editor.weightIp": "Вес {ip}",
+ "editor.costIp": "Стоимость {ip}",
+ "editor.removeIp": "Убрать {ip}",
+ "editor.ipAddress": "IP-адрес",
+ "editor.addIpForLabel": "Добавить IP для {label}",
+ "editor.add": "Добавить",
+ "editor.thisIpIsAlreadyIn": "Этот IP уже в списке.",
+ "editor.preferredIp2": "основной IP",
+ "editor.dnsPool": "DNS-пул",
+ "editor.weight2": "вес",
+ "editor.cost2": "стоимость",
+ "editor.twoServersOrADns": "Нужно два сервера или DNS-пул в режиме «Пул»",
+ "editor.name2": "Имя",
+ "editor.type": "Тип",
+ "editor.address": "Адрес",
+ "editor.port2": "Порт",
+ "editor.haproxyResolvesTheDomainAnd": "HAProxy резолвит домен и распределяет трафик по всем его IP. Только для домена.",
+ "editor.role": "Роль",
+ "editor.shareOfNewClientsRelative": "Доля новых клиентов относительно других серверов: 2 — вдвое больше, чем у сервера с весом 1. Пусто — 1.",
+ "editor.weightTolowercase": "Вес: {toLowerCase}.",
+ "editor.latencyMultiplier2TheServer": "Множитель задержки: 2 — сервер кажется вдвое медленнее. Пусто — 1.",
+ "editor.serverP0": "сервер {p0}",
+ "editor.ipDomain": "IP / домен",
+ "editor.ipOrDomain": "IP или домен",
+ "editor.dnsPoolIsAvailableFor": "DNS-пул доступен только для домена",
+ "editor.dnsPoolForLabelAll": "DNS-пул для {label}: все IP домена",
+ "editor.socketPath": "Путь socket",
+ "editor.backup": "Резерв",
+ "editor.primary": "Основной",
+ "editor.actionsLabel": "Действия: {label}",
+ "editor.up": "Выше",
+ "editor.down": "Ниже",
+ "editor.makePrimary": "Сделать основным",
+ "editor.makeBackup": "Сделать резервным",
+ "editor.removeServer": "Удалить сервер",
+ "editor.addServer": "Добавить сервер",
+ "editor.maximum16Servers": "Максимум — 16 серверов.",
+ "editor.loadIsSplitAcrossAll": "Нагрузка делится между всеми IP домена.",
+ "editor.loadIsSplitAcrossAll2": "Нагрузка делится между всеми серверами и всеми IP доменов с DNS-пулом.",
+ "editor.trafficGoesToThePreferred": "Трафик идёт на основной IP; если он упал — делится между всеми IP домена.",
+ "editor.setAPreferredIpIt": "Задайте основной IP — он получит весь трафик, остальные IP домена станут резервом.",
+ "editor.trafficIsSplitAcrossThe": "Трафик делится между основными серверами",
+ "editor.trafficGoesToThePrimary": "Трафик идёт на основной сервер",
+ "editor.ifAllPrimariesAreDown": "если все основные упали",
+ "editor.ifItIsDown": "если он упал",
+ "editor.headFallenItIsSplit": "{head}; {fallen} — делится между всеми резервными сразу.",
+ "editor.headBackupsTakeOverIn": "{head}; резервные включаются по порядку, {fallen}.",
+ "editor.firstServer": "Первый сервер",
+ "editor.algorithm": "Алгоритм",
+ "editor.howToPickAServer": "Как выбрать сервер для нового клиента.",
+ "editor.dynamicWeights": "Веса на лету",
+ "editor.offWeightsAreFixedPer": "Выкл — веса фиксированы: вес IP и плавный ввод не действуют.",
+ "editor.draws": "Выборок",
+ "editor.theLeastLoadedOfThis": "Из стольких случайных серверов берётся менее загруженный.",
+ "editor.tolerance": "Толерантность",
+ "editor.serversWhoseConnectionsPerUnit": "Серверы, где соединений на единицу веса больше минимума не более чем на столько, считаются равными.",
+ "editor.empty0": "Пусто — 0",
+ "editor.withBackupServersPlainLeastconn": "С резервными серверами используется обычный leastconn: оставьте толерантность 0 %.",
+ "editor.weightIsSetInThe": "Вес — в таблице серверов. Толерантность больше 0 применяет агент ноды каждые 5 с.",
+ "editor.requiresNodeAgentLeastconnTolerance": " Нужен Node Agent {LEASTCONN_TOLERANCE_MIN_AGENT}, на ноде {p1}.",
+ "editor.serversNoSlowerThanThe": "Кто медленнее лучшего не больше чем на столько — получает полный вес.",
+ "editor.toleranceAtLeast": "Толерантность не меньше",
+ "editor.differencesUpToThisMany": "Разница до стольких мс не в счёт. Пусто — выкл.",
+ "editor.ms": "мс",
+ "editor.off": "выкл",
+ "editor.latencyIsMeasuredByThe": "Задержку меряет проверка здоровья, агент ноды обновляет веса каждые 5 с. Стоимость — в таблице серверов.",
+ "editor.requiresNodeAgentKernelShaper": " Нужен Node Agent {KERNEL_SHAPER_MIN_AGENT}, на ноде {p1}.",
+ "editor.ipv6Clients": "IPv6-клиенты",
+ "editor.turnOffIfTheNode": "Выключите, если на ноде нет IPv6: таблица будет только для IPv4.",
+ "editor.on": "Вкл",
+ "editor.off2": "Выкл",
+ "editor.ipv6ClientSubnet": "Подсеть IPv6-клиента",
+ "editor.allAddressesInOneSubnet": "Все адреса из одной подсети считаются одним клиентом; /128 — каждый адрес отдельно.",
+ "editor.emptyIpv6default": "Пусто — /{ipv6Default}",
+ "editor.clientDistribution": "Распределение клиентов",
+ "editor.whichServerAClientGets": "Какой сервер получает клиент: выбирается заново или всегда тот же.",
+ "editor.distribution": "Распределение",
+ "editor.moreEvenButAlmostAll": "Ровнее, но при смене серверов переезжают почти все.",
+ "editor.onlyTheAffectedServersClients": "При смене серверов переезжают только их клиенты.",
+ "editor.stable": "Стабильное",
+ "editor.even": "Ровное",
+ "editor.overloadLimit": "Лимит перегрузки",
+ "editor.stableDistributionOnly": "Только для стабильного распределения.",
+ "editor.noMoreThanThisOf": "Не больше этого % от средней нагрузки. Пусто — выкл.",
+ "editor.rememberFor": "Помнить",
+ "editor.howLongAClientStays": "Сколько клиент держится за сервер после последнего соединения.",
+ "editor.timeUnit": "Единица времени",
+ "editor.quickDuration": "Быстрый выбор срока",
+ "editor.clientsInMemory": "Клиентов в памяти",
+ "editor.memoryIsUsedAsClients": "Память занимается по мере прихода клиентов; запись удаляется через «Помнить» без соединений. Когда места нет, самые старые забываются.",
+ "editor.memoryWhenFull": "{memory} при заполнении",
+ "editor.set": "Задать",
+ "editor.clients": "клиентов",
+ "editor.theTableSurvivesHaproxyReloads": "Таблица переживает перезагрузку HAProxy.",
+ "editor.aClientThatLandedOn": "Клиент, попавший на резерв, остаётся на нём до истечения «Помнить», даже когда основные серверы вернулись.",
+ "editor.sourceRouteNotFound": "Исходный маршрут не найден",
+ "editor.p0Copy": "{p0} — копия",
+ "editor.routeNotSaved": "Маршрут не сохранён",
+ "editor.kernelshaperagenthintChooseHaproxyOrUpdate": "{kernelShaperAgentHint}. Выберите HAProxy или обновите агент.",
+ "editor.theRouteWasChangedIn": "Маршрут изменился в другой вкладке. Обновите данные и повторите.",
+ "editor.draftSavedButTheRoute": "Черновик сохранён, но включить маршрут не удалось: {detail}",
+ "editor.loadingNodeRoutesAndChecking": "Загружаем маршруты ноды и проверяем конфликты портов…",
+ "editor.editorFailedToLoad": "Редактор не загрузился",
+ "editor.nodeOrRouteNotFound": "Нода или маршрут не найдены",
+ "editor.backToNodes": "К нодам",
+ "editor.kernelshaperagenthintCurrentlyAgentversionP2": "{kernelShaperAgentHint} (сейчас {agentVersion}).{p2}",
+ "editor.acceptsProxy": "принимает PROXY",
+ "editor.pinnedByIp": "закрепление по IP",
+ "editor.stickinessAlgorithmlabel": "запоминание · {algorithmLabel}",
+ "editor.pool": "пул",
+ "editor.primaryBackups": "основной + резервные",
+ "editor.limitQuotavalueQuotaunit": "лимит {quotaValue} {quotaUnit}",
+ "editor.speedP0MbitSP1": "скорость {p0} Мбит/с{p1}",
+ "editor.noTrafficOrSpeedLimits": "Лимит трафика и скорости не заданы",
+ "editor.saving": "Сохраняем…",
+ "editor.notSaved": "Не сохранено",
+ "editor.unsavedChanges": "Есть несохранённые изменения",
+ "editor.noChanges": "Изменений нет",
+ "editor.newRoute": "Новый маршрут",
+ "editor.updateTemplate": "Обновить шаблон",
+ "editor.templateSaved": "Шаблон сохранён",
+ "editor.backToNode": "Назад к ноде",
+ "editor.templateP0": "Шаблон: {p0}",
+ "editor.status": "Состояние:",
+ "editor.enabled": "Включён",
+ "editor.connection": "Подключение",
+ "editor.routeName": "Имя маршрута",
+ "editor.visibleToOperatorsOnly": "Видно только оператору.",
+ "editor.routeSelection": "Выбор маршрута",
+ "editor.bySniThePortCan": "По SNI; порт можно делить.",
+ "editor.everythingThatDidNotMatch": "Всё, что не совпало по SNI.",
+ "editor.bySni": "По SNI",
+ "editor.listenOn": "Слушать",
+ "editor.allNodeAddresses": "Все адреса ноды.",
+ "editor.aSingleNodeAddress": "Один адрес ноды.",
+ "editor.allIps": "Все IP",
+ "editor.singleIp": "Один IP",
+ "editor.allAddresses": "все адреса",
+ "editor.listenerIpAddress": "IP-адрес listener",
+ "editor.tcpOnTheNode": "TCP на ноде.",
+ "editor.sniDomains": "Домены SNI",
+ "editor.enterSpaceOrCommaNext": "Enter, пробел или запятая — следующий домен.",
+ "editor.theFallbackReceivesConnectionsWith": "Фолбэк получает соединения без совпавшего SNI.",
+ "editor.notNeededForAFallback": "не нужны для фолбэка",
+ "editor.acceptProxyProtocol": "Принимать PROXY protocol",
+ "editor.aProxyInFrontOf": "Прокси перед нодой передаёт IP клиента.",
+ "editor.trustedAddresses": "Доверенные адреса",
+ "editor.ipsSubnetsOrDomainsDomain": "IP, подсети или домены (IP домена обновляются сами). Пусто — ждать PROXY от всех.",
+ "editor.clientsWithoutProxyProtocolWill": "Клиенты без PROXY protocol не подключатся к этому порту — это действует на все маршруты порта.",
+ "editor.servers2": "Серверы",
+ "editor.addASecondServerOr": "Добавьте второй сервер или DNS-пул, чтобы делить нагрузку.",
+ "editor.twoServersOrADns2": "Нужно два сервера или DNS-пул",
+ "editor.pool2": "Пул",
+ "editor.primaryBackups2": "Основной + резервные",
+ "editor.howToDistributeTraffic": "Как распределять трафик",
+ "editor.serverConnection": "Соединение с серверами",
+ "editor.howTheNodeConnectsTo": "Как нода подключается к серверам.",
+ "editor.healthCheck": "Проверка здоровья",
+ "editor.alwaysOnForADns": "Для DNS-пула включена всегда.",
+ "editor.tcpEvery5SAn": "TCP каждые 5 с; недоступный сервер исключается.",
+ "editor.proxyProtocolToServer": "PROXY protocol к серверу",
+ "editor.passTheClientIpThe": "Передать IP клиента; сервер должен принимать PROXY.",
+ "editor.withoutProxySupportOnThe": "Без поддержки PROXY на сервере соединения не пройдут.{p0}",
+ "editor.no": "Нет",
+ "editor.slowStartAfterRecovery": "Плавный ввод после восстановления",
+ "editor.twoServersOrADns3": "Нужно два сервера или DNS-пул.",
+ "editor.requiresAHealthCheck": "Требует проверки здоровья.",
+ "editor.noEffectWeightsAreFixed": "Не действует: веса фиксированы.",
+ "editor.trafficToARecoveredServer": "Трафик на вернувшийся сервер растёт постепенно. Пусто — сразу.",
+ "editor.s": "с",
+ "editor.advanced": "Дополнительно",
+ "editor.trafficLimit2": "Лимит трафика",
+ "editor.routeTrafficCounterWithA": "Счётчик трафика маршрута со сбросом по расписанию.",
+ "editor.limit": "Лимит",
+ "editor.limitUnit": "Единица лимита",
+ "editor.reset": "Сброс",
+ "editor.whenReached": "При достижении",
+ "editor.newConnectionsAreRefusedActive": "Новые соединения не пройдут; активные сессии не разрываются.",
+ "editor.thePanelWillHighlightThe": "Панель подсветит превышение, трафик продолжит идти.",
+ "editor.notifyOnly": "Только уведомить",
+ "editor.blockNewConnections": "Блокировать новые",
+ "editor.speedLimit": "Ограничение скорости",
+ "editor.perClientIpAcrossAll": "На IP клиента, для всех его соединений в маршруте.",
+ "editor.fromClient": "От клиента",
+ "editor.emptyNoLimit": "Пусто — без лимита.",
+ "editor.toClient": "К клиенту",
+ "editor.whereToLimit": "Где ограничивать",
+ "editor.nftablesOnTheNodeSplice": "nftables на ноде, splice сохраняется. Маршруты порта должны иметь одинаковые лимиты.",
+ "editor.exactPerRouteSniBut": "Точно по маршруту (SNI), но отключает splice на этом порту.",
+ "editor.kernelNftables": "Ядро (nftables)",
+ "editor.unknown": "неизвестно",
+ "editor.needsFixingLength": "Нужно исправить: {length}",
+ "editor.needsFixing": "Нужно исправить:",
+ "editor.saveTemplate": "Сохранить шаблон",
+ "editor.saveDraft": "Сохранить черновик",
+ "editor.saveAndApply": "Сохранить и применить",
+ "editor.saveAndEnable": "Сохранить и включить",
+ "editor.haproxyPreview": "Предпросмотр HAProxy",
+ "editor.formIsValid": "Форма заполнена верно",
+ "editor.errorsLength": "Ошибок: {length}",
+ "editor.fillInTheRequiredFields": "Заполните обязательные поля",
+ "editor.dedicatedFrontend": "Отдельный frontend",
+ "editor.hasManualDirectives": "· есть ручные директивы",
+ "editor.beforeApplyingTheAgentValidates": "Перед применением Agent проверит конфиг через",
+ "editor.ufwChangesOnlyAccordingTo": "; UFW меняется только по политике ноды. Активные соединения не разрываются.",
+ "editor.haproxyPreviewReadOnly": "Предпросмотр HAProxy, только чтение",
+ "editor.readOnly": "только чтение",
+ "editor.manualBackendDirectives": "Ручные директивы backend",
+ "editor.globalFrontendListenBackendAnd": "Секции global, frontend, listen, backend и resolvers запрещены.",
+ "editor.allowManualDirectives": "Разрешить ручные директивы",
+ "editor.backendDirectives": "Директивы backend",
+ "editor.leaveWithoutSaving": "Выйти без сохранения?",
+ "editor.routeChangesWillBeLost": "Изменения маршрута будут потеряны. HAProxy и UFW не изменятся.",
+ "editor.continue": "Продолжить",
+
+ // ── routeModel
+ "routeModel.byAlgorithm": "По алгоритму",
+ "routeModel.eachNewConnectionGetsA": "Каждое новое соединение получает сервер по алгоритму.",
+ "routeModel.pinByIp": "Закреплять по IP",
+ "routeModel.theServerIsDeterminedBy": "Сервер определяется IP клиента, алгоритм не нужен.",
+ "routeModel.rememberClient": "Запоминать клиента",
+ "routeModel.theAlgorithmPicksTheFirst": "Первый сервер выбирает алгоритм, дальше клиент остаётся на нём.",
+ "routeModel.s": "сек",
+ "routeModel.min": "мин",
+ "routeModel.h": "ч",
+ "routeModel.k": "тыс.",
+ "routeModel.m": "млн",
+ "routeModel.auto1000000Node": "Авто: 1 000 000, RAM ноды неизвестна",
+ "routeModel.autoFormatcountClients5Of": "Авто: ~{formatCount} клиентов (5% RAM ноды), {tableMemoryEstimate} при полной таблице",
+ "routeModel.p0Gb": "≈{p0} ГБ",
+ "routeModel.roundMb": "≈{round} МБ",
+ "routeModel.p0Mb": "≈{p0} МБ",
+ "routeModel.roundRobin": "По кругу",
+ "routeModel.serversGetClientsInTurn": "Серверы получают клиентов по очереди, с учётом веса.",
+ "routeModel.random": "Случайно",
+ "routeModel.aRandomServerWeighted": "Случайный сервер с учётом веса.",
+ "routeModel.fewestConnections": "Меньше соединений",
+ "routeModel.theServerWithTheFewest": "Сервер, где сейчас меньше всего соединений.",
+ "routeModel.lowestLatency": "Меньше задержка",
+ "routeModel.moreTrafficToServersWith": "Больше трафика серверам с меньшей задержкой (как leastLoad в Xray).",
+ "routeModel.hourly": "Каждый час",
+ "routeModel.resetsAtTheStartOf": "Сбрасывается в начале следующего часа.",
+ "routeModel.daily": "Ежедневно",
+ "routeModel.resetsAt0000Utc": "Сбрасывается в 00:00 UTC.",
+ "routeModel.calendarMonth": "Календарный месяц",
+ "routeModel.resetsOnThe1stAt": "Сбрасывается первого числа в 00:00 UTC.",
+ "routeModel.monthFromCreation": "Месяц от создания",
+ "routeModel.theWindowStartsOnThe": "Окно начинается в дату создания маршрута.",
+ "routeModel.expertLayerExceeds8192Bytes": "Экспертный слой превышает 8192 байт.",
+ "routeModel.expertLayerMustBeValid": "Экспертный слой должен быть корректным UTF-8.",
+ "routeModel.expertLayerContainsAControl": "Экспертный слой содержит управляющий символ.",
+ "routeModel.lineContinuationAndEscapingDirectives": "Перенос и экранирование директив через \\\\ запрещены.",
+ "routeModel.oneOfTheDirectivesIs": "Одна из директив длиннее 512 байт.",
+ "routeModel.theDirectiveSectionIsManaged": "Секция {directive} управляется NodeFlow и не может быть переопределена.",
+ "routeModel.expertLayerExceeds8192Bytes2": "Экспертный слой превышает 8192 байт после нормализации.",
+ "routeModel.withBackupsChooseAnHaproxy": "С резервом выберите алгоритм HAProxy, например «Меньше соединений».",
+ "routeModel.withBackupsToleranceMustBe": "С резервом толерантность должна быть 0 %.",
+ "routeModel.drawsMustBeFrom1": "Выборок — от 1 до 5.",
+ "routeModel.toleranceMustBeAnInteger": "Толерантность — целое число от 0 до 100 %.",
+ "routeModel.toleranceInMsMustBe": "Толерантность в мс — от 0 до 1000.",
+ "routeModel.overloadLimitMustBeFrom": "Лимит перегрузки — от 101 до 1000 % или пусто.",
+ "routeModel.ipv6PrefixMustBeFrom": "Префикс IPv6 — от /32 до /128.",
+ "routeModel.rememberForMustBeFrom": "Помнить — от 1 минуты до 168 часов.",
+ "routeModel.maxClientsMustBeFrom": "Макс. клиентов — от 1 до {formatCount}.",
+ "routeModel.enterARouteNameIt": "Укажите имя маршрута. Оно видно только оператору.",
+ "routeModel.routeNameMustNotExceed": "Имя маршрута не должно превышать 80 символов.",
+ "routeModel.routeNameMustNotContain": "Имя маршрута не должно содержать табуляцию и управляющие символы.",
+ "routeModel.listenerAddressMustBeOr": "Адрес listener должен быть * или корректным IP.",
+ "routeModel.listenerPortMustBeFrom": "Порт listener должен быть от 1 до 65535.",
+ "routeModel.addAtLeastOneSni": "Добавьте хотя бы один SNI.",
+ "routeModel.noMoreThan64Snis": "Можно указать не больше 64 SNI.",
+ "routeModel.eachSniMustBeA": "Каждый SNI должен быть корректным DNS-именем.",
+ "routeModel.snisWithinARouteMust": "SNI внутри маршрута не должны повторяться.",
+ "routeModel.theListMustNotExceed": "Список не должен превышать 256 записей.",
+ "routeModel.invalidAddressSubnetOrDomain": "Некорректный адрес, подсеть или домен: {entry}",
+ "routeModel.noMoreThanMaxAccept": "Не больше {MAX_ACCEPT_PROXY_DOMAINS} доменов в списке.",
+ "routeModel.addAtLeastOneDestination": "Добавьте хотя бы один сервер назначения.",
+ "routeModel.noMoreThan16Servers": "Не более 16 серверов.",
+ "routeModel.serverNameTrimMustNot": "Имя сервера {trim} не должно оканчиваться на _pref.",
+ "routeModel.serverNamesMustNotRepeat": "Имена серверов не должны повторяться.",
+ "routeModel.serverP0EnterAName": "Сервер {p0}: укажите имя.",
+ "routeModel.serverP0NameLettersDigits": "Сервер {p0}: имя — буквы, цифры, _, - (до {SERVER_NAME_MAX} символов).",
+ "routeModel.serverP0EnterAnAddress": "Сервер {p0}: укажите адрес.",
+ "routeModel.serverP0HostIsNot": "Сервер {p0}: {host} — некорректный IPv4-адрес.",
+ "routeModel.serverP0AddressMustBe": "Сервер {p0}: адрес должен быть IP или доменом.",
+ "routeModel.serverP0InvalidPort": "Сервер {p0}: некорректный порт.",
+ "routeModel.serverP0DnsPoolIs": "Сервер {p0}: DNS-пул недоступен для IP-адресов.",
+ "routeModel.serverP0PreferredIpIs": "Сервер {p0}: основной IP — некорректный адрес.",
+ "routeModel.serverP0InvalidUnixSocket": "Сервер {p0}: некорректный путь Unix socket.",
+ "routeModel.serverP0WeightMustBe": "Сервер {p0}: вес — целое число от 1 до 256.",
+ "routeModel.serverP0CostMustBe": "Сервер {p0}: стоимость — число больше 0 и не больше 100.",
+ "routeModel.serverP0PerIpWeight": "Сервер {p0}: вес и стоимость IP доступны только для DNS-пула.",
+ "routeModel.serverP0NoMoreThan": "Сервер {p0}: не более 32 отдельных IP.",
+ "routeModel.serverP0IpsMustNot": "Сервер {p0}: IP не должны повторяться.",
+ "routeModel.serverP0InvalidIpIn": "Сервер {p0}: некорректный IP в списке отдельных IP.",
+ "routeModel.serverP0PerIpWeight2": "Сервер {p0}: вес IP — целое число от 1 до 256.",
+ "routeModel.serverP0PerIpCost": "Сервер {p0}: стоимость IP — число от 0,01 до 100.",
+ "routeModel.serverP0PerIpWeight3": "Сервер {p0}: вес IP не работает с фиксированными весами — включите «Веса меняются на лету» или «Стабильное» распределение.",
+ "routeModel.markAtLeastOneServer": "Отметьте хотя бы один сервер как «Основной».",
+ "routeModel.markAtLeastOneServer2": "Отметьте хотя бы один сервер как «Резерв» или выберите режим «Пул».",
+ "routeModel.withAPreferredIpThe": "С основным IP остальные IP домена уже служат резервом — уберите основной IP или резервные серверы.",
+ "routeModel.aBackupServerWithA": "Резервный сервер с DNS-пулом должен быть единственным резервным.",
+ "routeModel.serverNameTrimClashesWith": "Имя сервера {trim} совпадает с именами адресов DNS-пула {p1}.",
+ "routeModel.theLimitMustBeA": "Лимит должен быть положительным числом в безопасном диапазоне.",
+ "routeModel.blockingNewConnectionsRequiresThe": "Блокировка новых соединений требует включённого лимита.",
+ "routeModel.blockingNewConnectionsIsAvailable": "Блокировка новых соединений доступна только для одного сервера без DNS-пула. Выберите «Только уведомить».",
+ "routeModel.uploadLimit": "Лимит upload",
+ "routeModel.downloadLimit": "Лимит download",
+ "routeModel.labelMustBeAnInteger": "{label} должен быть целым числом от 1 до 1 000 000 Mbps либо пустым.",
+ "routeModel.slowStartRequiresTheHealth": "Плавный ввод требует включённой проверки здоровья.",
+ "routeModel.slowStartMustBeFrom": "Плавный ввод — от 1 до 600 секунд.",
+ "routeModel.withADnsPoolThe": "С DNS-пулом экспертный слой не может задавать balance, hash-type, server, server-template и default-server.",
+ "routeModel.portListenerportIsAlreadyListened": "Порт {listenerPort} уже слушается {p1}. Нельзя смешивать wildcard и конкретный IP.",
+ "routeModel.listeneripListenerportAlreadyHasA": "Для {listenerIP}:{listenerPort} уже есть фолбэк-маршрут.",
+ "routeModel.sniDuplicateIsAlreadyAssigned": "SNI {duplicate} уже назначен другому маршруту на {listenerIP}:{listenerPort}.",
+ "routeModel.onListeneripListenerportRoutePeername": "На {listenerIP}:{listenerPort} маршрут {peerName} ограничивает скорость через {p3}; режимы на одном listener смешивать нельзя.",
+ "routeModel.kernelLimitsOnListeneripListenerport": "Лимиты ядра на {listenerIP}:{listenerPort} должны совпадать с маршрутом {peerName} (↑{p3} / ↓{p4} Mbps).",
+ "routeModel.otherNameserversAndHoldParameters": "# … остальные nameserver и hold-параметры",
+
+ // ── templates
+ "templates.copiesAreCreatedDisabledCounters": "Копии создаются выключенными; счётчики и история не переносятся.",
+ "templates.onAnotherNodeCheckThe": "На другой ноде проверьте listener IP и Unix-сокеты перед включением.",
+ "templates.targetNodes": "Целевые ноды",
+ "templates.selectNodes": "Выберите ноды",
+ "templates.nameThisNode": "{name} (эта нода)",
+ "templates.resultPerNode": "Результат по нодам",
+ "templates.draftCreated": "Черновик создан",
+ "templates.open": "Открыть",
+ "templates.nodeListUnavailable": "Список нод недоступен",
+ "templates.copiesNotCreated": "Копии не созданы",
+ "templates.copy": "Копировать",
+ "templates.templateNotSaved": "Шаблон не сохранён",
+ "templates.changingATemplateDoesNot": "Изменение шаблона не меняет созданные из него маршруты.",
+ "templates.name": "Название",
+ "templates.description": "Описание",
+ "templates.optional": "Необязательно",
+ "templates.fallback": "фолбэк",
+ "templates.templatesUnavailable": "Шаблоны недоступны",
+ "templates.routesNotCreated": "Маршруты не созданы",
+ "templates.templateNotDeleted": "Шаблон не удалён",
+ "templates.loading": "Загрузка…",
+ "templates.noTemplatesSaveARoute": "Шаблонов нет. Сохраните маршрут как шаблон из меню маршрута.",
+ "templates.templates": "Шаблоны",
+ "templates.configureForThisNode": "Настроить для этой ноды",
+ "templates.editTemplate": "Редактировать шаблон",
+ "templates.createRoutes": "Создать маршруты",
+ "templates.deleteTemplate": "Удалить шаблон?",
+ "templates.template": "Шаблон",
+ "templates.willBeDeletedRoutesCreated": "будет удалён. Созданные из него маршруты не изменятся.",
+ "templates.templateNotFound": "Шаблон не найден",
+ "templates.nameCopy": "{name} (копия)",
+
+ // ── plural / hand-written
+ "editor.sniDomainsCount": { one: "SNI · {count} домен", few: "SNI · {count} домена", many: "SNI · {count} доменов", other: "SNI · {count} домена" },
+ "editor.serversCount": { one: "{count} сервер", few: "{count} сервера", many: "{count} серверов", other: "{count} сервера" },
+ "editor.sharedFrontendWith": { one: "Общий frontend с {count} маршрутом", other: "Общий frontend с {count} маршрутами" },
+ "app.language": "Язык",
+ "app.languageRu": "Русский",
+ "app.languageEn": "English",
+ "editor.clientIpFromProxy": "IP клиента берётся из PROXY protocol, если он приходит, иначе — адрес соединения.",
+ "nodes.routesEnabled": "{count} включено",
+ "nodes.showingNodes": { one: "Показано {from}–{to} из {count} ноды", other: "Показано {from}–{to} из {count} нод" },
+ "node.showingRoutes": { one: "Показано {from}–{to} из {count} маршрута", other: "Показано {from}–{to} из {count} маршрутов" },
+ "app.perPage": "{size} / стр.",
+ "node.newPerSecond": "{rate} новых/с",
+ "node.totalCount": "{total} всего",
+ "node.rateNotReceived": "Rate не получен",
+ "node.totalNotReceivedLower": "total не получен",
+ "editor.perIpWeightsAreApplied": "Веса отдельных IP применяет агент ноды — обновите Node Agent до {version} или новее.",
+ "editor.kernelNodeAgentRequired": "Ядро: нужен Node Agent {version}, на ноде {current}",
+ // ── api error codes (backend `error.code`)
+ "apiError.not_found": "Объект не найден.",
+ "apiError.internal_error": "Внутренняя ошибка Panel. Подробности — в логах сервера.",
+ "apiError.unauthorized": "Сессия истекла или токен неверен. Войдите снова.",
+ "apiError.invalid_origin": "Запрос должен идти с того же адреса, что и Panel.",
+ "apiError.already_exists": "Такой объект уже существует.",
+ "apiError.stale_route_version": "Маршрут изменён другой операцией. Обновите страницу и повторите.",
+ "apiError.stale_order": "Список изменился. Обновите страницу и повторите.",
+ "apiError.stale_haproxy_control": "Состояние HAProxy изменилось. Обновите страницу и повторите.",
+ "apiError.haproxy_control_unsupported": "Обновите Node Agent, чтобы управлять HAProxy.",
+ "apiError.haproxy_disabled": "Включите HAProxy перед перезапуском.",
+ "apiError.advanced_config_active": "Включён ручной конфиг HAProxy. Вернитесь к сгенерированному конфигу, чтобы менять маршруты.",
+ "apiError.database_unavailable": "База данных недоступна.",
+ "apiError.route_copy_failed": "Не удалось создать черновик на ноде",
+ "apiError.node_not_found": "Нода не найдена",
+ "apiError.release_in_use": "Релиз установлен или назначен на ноду.",
+ "apiError.bootstrap_job_limit": "Слишком много задач установки. Дождитесь завершения активной.",
+ "apiError.rate_limited": "Слишком много одновременных DNS-запросов. Повторите позже.",
+ "apiError.invalid_host": "Укажите DNS-имя, а не IP-адрес.",
+} as const satisfies Record;
+
+export type MessageKey = keyof typeof ru;
diff --git a/frontend/src/pages/HAProxyConfigPage.tsx b/frontend/src/pages/HAProxyConfigPage.tsx
index 336ed09..7defa9f 100644
--- a/frontend/src/pages/HAProxyConfigPage.tsx
+++ b/frontend/src/pages/HAProxyConfigPage.tsx
@@ -16,6 +16,7 @@ import type { ConfigRevision, NodeConfigState, NodeRecord } from '../lib/contrac
import { demoSuffix } from '../lib/navigation';
import { HAProxyApplyErrorDetail } from './HAProxyApplyErrorDetail';
import './haproxy-config.css';
+import { locale } from '../i18n';
type Baseline = { text: string; revision: number | null };
type LintView = LintResult & { checkedAt: Date; local: boolean };
@@ -364,8 +365,8 @@ export function HAProxyConfigPage() {
const statusText = !lint
? 'Проверка…'
: errors || warnings
- ? `${lint.checkedAt.toLocaleTimeString('ru-RU')} | ${[errors ? `${errors} ${plural(errors, 'ошибка', 'ошибки', 'ошибок')}` : '', warnings ? `${warnings} ${plural(warnings, 'предупреждение', 'предупреждения', 'предупреждений')}` : ''].filter(Boolean).join(', ')}`
- : `${lint.checkedAt.toLocaleTimeString('ru-RU')} | Конфиг валиден`;
+ ? `${lint.checkedAt.toLocaleTimeString(locale())} | ${[errors ? `${errors} ${plural(errors, 'ошибка', 'ошибки', 'ошибок')}` : '', warnings ? `${warnings} ${plural(warnings, 'предупреждение', 'предупреждения', 'предупреждений')}` : ''].filter(Boolean).join(', ')}`
+ : `${lint.checkedAt.toLocaleTimeString(locale())} | Конфиг валиден`;
const statusTone = !lint ? 'is-pending' : errors ? 'is-error' : warnings ? 'is-warning' : 'is-valid';
const history = revisions.map((revision) => ({
value: String(revision.revision),
diff --git a/frontend/src/pages/NodeDetailPage.tsx b/frontend/src/pages/NodeDetailPage.tsx
index 15a55b5..a2d9255 100644
--- a/frontend/src/pages/NodeDetailPage.tsx
+++ b/frontend/src/pages/NodeDetailPage.tsx
@@ -21,8 +21,9 @@ import type { TrafficRange } from '../features/nodes/useNodesOverview';
import { api, isUnauthorized } from '../lib/api';
import type { AgentRelease, FirewallMode, HAProxyControlState, NodeAgentUpdateState, NodeFirewallPolicy, NodeRecord, RouteRecord } from '../lib/contracts';
import { timeAgo } from '../lib/format';
+import { t } from '../i18n';
-const stateCopy = { online: 'Работает', degraded: 'Требует внимания', offline: 'Недоступна', stopped: 'HAProxy выключен' } as const;
+const stateCopy = { online: t('nodes.online3'), degraded: t('node.needsAttention'), offline: t('nodes.unreachable2'), stopped: t('node.haproxyIsStopped') } as const;
/**
* Enable/disable goes through PATCH, which only flips `enabled` and keeps the
@@ -109,7 +110,7 @@ export function NodeDetailPage() {
if (result.isPending && !detail) return ;
if (result.isError && isUnauthorized(result.error)) return result.refetch()} />;
if (!detail || !bundle) {
- return navigate(`/nodes${demoQuery}`)}>К списку нод result.refetch()} />>} /> ;
+ return navigate(`/nodes${demoQuery}`)}>{t('node.backToNodes')} result.refetch()} />>} /> ;
}
const metrics = bundle.operational?.latest_heartbeat?.metrics;
@@ -128,14 +129,14 @@ export function NodeDetailPage() {
const freshnessWindow = Math.max(45_000, (bundle.history?.bucket_seconds ?? 15) * 2_500);
const historyStale = Boolean(latestSample && (!Number.isFinite(latestSampleAt) || Date.now() - latestSampleAt > freshnessWindow));
const chartStatus = result.isError || historyStale
- ? { tone: 'stale' as const, label: 'Данные устарели' }
+ ? { tone: 'stale' as const, label: t('nodes.dataIsStale') }
: !latestSample
- ? { tone: 'offline' as const, label: 'Нет свежих данных' }
+ ? { tone: 'offline' as const, label: t('nodes.noFreshData') }
: status === 'offline'
- ? { tone: 'offline' as const, label: 'Нода недоступна' }
+ ? { tone: 'offline' as const, label: t('nodes.nodeIsUnreachable') }
: status === 'degraded' || !rateFresh
- ? { tone: 'degraded' as const, label: rateFresh ? 'Нода деградирует' : 'Нет текущей точки' }
- : { tone: 'online' as const, label: 'Онлайн' };
+ ? { tone: 'degraded' as const, label: rateFresh ? t('nodes.nodeIsDegraded') : t('node.noCurrentSample') }
+ : { tone: 'online' as const, label: t('nodes.online') };
const firewall = firewallOverride ?? detail.firewall;
const update = updateOverride ?? detail.update;
const node = nodeOverride ?? bundle.node;
@@ -178,7 +179,7 @@ export function NodeDetailPage() {
setNodeDeleteOpened(false);
navigate(`/nodes${demoQuery}`, { replace: true });
} catch (error) {
- setNodeDeleteError(error instanceof Error ? error.message : 'Нода не удалена');
+ setNodeDeleteError(error instanceof Error ? error.message : t('node.nodeNotDeleted'));
} finally {
setNodeDeleteBusy(false);
}
@@ -188,12 +189,12 @@ export function NodeDetailPage() {
const name = nodeName.trim();
const address = nodeAddress.trim();
if (!name || !address) {
- setNodeEditError('Укажите название и IP-адрес ноды.');
+ setNodeEditError(t('node.enterTheNodeNameAnd'));
return;
}
setNodeTuningTouched(true);
if (Object.keys(validateHAProxyTuning(nodeTuning)).length) {
- setNodeEditError('Исправьте параметры HAProxy.');
+ setNodeEditError(t('node.fixTheHaproxySettings'));
return;
}
const tuning = haproxyTuningPayload(nodeTuning);
@@ -212,7 +213,7 @@ export function NodeDetailPage() {
if (!demo) await invalidate();
setNodeSettingsOpened(false);
} catch (error) {
- setNodeEditError(error instanceof Error ? error.message : 'Параметры ноды не сохранены');
+ setNodeEditError(error instanceof Error ? error.message : t('node.nodeSettingsNotSaved'));
} finally {
setNodeEditBusy(false);
}
@@ -233,7 +234,7 @@ export function NodeDetailPage() {
setRouteOverrides((current) => { const { [route.id]: _omit, ...rest } = current; return rest; });
}
} catch (error) {
- setActionError(error instanceof Error ? error.message : 'Маршрут не изменён');
+ setActionError(error instanceof Error ? error.message : t('node.routeNotUpdated'));
} finally {
setBusyRouteID('');
}
@@ -248,7 +249,7 @@ export function NodeDetailPage() {
await invalidate();
}
} catch (error) {
- setActionError(error instanceof Error ? error.message : 'Маршрут не удалён');
+ setActionError(error instanceof Error ? error.message : t('node.routeNotDeleted'));
throw error;
} finally {
setBusyRouteID('');
@@ -273,7 +274,7 @@ export function NodeDetailPage() {
}
} catch (error) {
setRouteOrder(previous);
- setActionError(error instanceof Error ? error.message : 'Порядок маршрутов не сохранён');
+ setActionError(error instanceof Error ? error.message : t('node.routeOrderNotSaved'));
if (!demo) void invalidate();
} finally {
setOrderingRouteID('');
@@ -308,7 +309,7 @@ export function NodeDetailPage() {
setHAProxyOverride(null);
}
} catch (error) {
- setActionError(error instanceof Error ? error.message : `HAProxy не ${enabled ? 'включён' : 'выключен'}`);
+ setActionError(error instanceof Error ? error.message : t('node.haproxyNotP0', { p0: enabled ? t('misc.started') : t('misc.stopped') }));
} finally {
setHAProxyBusy(false);
}
@@ -341,7 +342,7 @@ export function NodeDetailPage() {
setHAProxyOverride(null);
}
} catch (error) {
- setActionError(error instanceof Error ? error.message : 'HAProxy не перезагружен');
+ setActionError(error instanceof Error ? error.message : t('node.haproxyNotRestarted'));
} finally {
setHAProxyBusy(false);
}
@@ -357,7 +358,7 @@ export function NodeDetailPage() {
setFirewallOverride(value);
}
} catch (error) {
- setActionError(error instanceof Error ? error.message : 'Режим UFW не изменён');
+ setActionError(error instanceof Error ? error.message : t('node.ufwModeNotChanged'));
} finally {
setBusyAction('');
}
@@ -365,7 +366,7 @@ export function NodeDetailPage() {
const assignRelease = async (release: AgentRelease) => {
if (!update) {
- setActionError('Состояние Node Agent недоступно. Обновите данные и повторите.');
+ setActionError(t('node.nodeAgentStatusIsUnavailable'));
return;
}
setBusyAction('update'); setActionError('');
@@ -383,7 +384,7 @@ export function NodeDetailPage() {
setUpdateOverride(value);
}
} catch (error) {
- setActionError(error instanceof Error ? error.message : 'Релиз не назначен');
+ setActionError(error instanceof Error ? error.message : t('node.releaseNotScheduled'));
} finally {
setBusyAction('');
}
@@ -405,7 +406,7 @@ export function NodeDetailPage() {
setUpdateOverride(value);
}
} catch (error) {
- setActionError(error instanceof Error ? error.message : 'Откат не назначен');
+ setActionError(error instanceof Error ? error.message : t('node.rollbackNotScheduled'));
throw error;
} finally {
setBusyAction('');
@@ -417,55 +418,55 @@ export function NodeDetailPage() {
setLibraryOpened(false)} nodeID={nodeId} />
Ноды/ {node.name} >}
+ breadcrumb={<> {t('app.nodes')}/ {node.name} >}
title={node.name}
- meta={<>{node.address} {stateCopy[displayStatus]}Последний сигнал: {timeAgo(node.last_seen_at)} >}
+ meta={<>{node.address} {stateCopy[displayStatus]}{t('node.lastHeartbeat')} {timeAgo(node.last_seen_at)} >}
actions={<>
- }>Добавить маршрут
- setLibraryOpened(true)}>Шаблоны маршрутов
- }>Конфигурация HAProxy
-
+ }>{t('nodes.addRoute')}
+ setLibraryOpened(true)}>{t('node.routeTemplates')}
+ }>{t('node.haproxyConfiguration')}
+
-
+
- } onClick={refresh}>Обновить данные
- } onClick={openNodeSettings}>Редактировать ноду
- } onClick={openReinstall}>Переустановить Node Agent
+ } onClick={refresh}>{t('node.refreshData')}
+ } onClick={openNodeSettings}>{t('node.editNode')}
+ } onClick={openReinstall}>{t('node.reinstallNodeAgent')}
}
disabled={!haproxyControl?.supported || !haproxyEnabled || haproxyBusy}
onClick={() => setHAProxyRestartConfirmOpened(true)}
- >Жёстко перезагрузить HAProxy
+ >{t('node.hardRestartHaproxy')}
}
disabled={!haproxyControl?.supported || haproxyBusy}
onClick={() => { if (haproxyEnabled) setHAProxyConfirmOpened(true); else void setHAProxyEnabled(true); }}
- >{haproxyEnabled ? 'Выключить HAProxy' : 'Включить HAProxy'}
- } onClick={openNodeDelete}>Удалить ноду
+ >{haproxyEnabled ? t('node.stopHaproxy') : t('node.startHaproxy')}
+ } onClick={openNodeDelete}>{t('node.deleteNode')}
>}
/>
- {status !== 'online' && haproxyEnabled && {status === 'offline' ? 'Нода не отвечает.' : 'Нода работает с ограничениями.'} {status === 'offline' ? 'Показаны последние сохранённые значения; управляющие действия могут ждать восстановления связи.' : 'Проверьте HAProxy runtime, backend health и состояние применения маршрутов.'}
}
- {!haproxyEnabled && HAProxy выключен. Node Agent остаётся на связи, но listener-порты и все маршруты остановлены.
}
- {haproxyControl && !haproxyControl.supported && Управление HAProxy недоступно. Обновите Node Agent до версии с поддержкой включения и выключения сервиса.
}
- {actionError && {actionError} setActionError('')}>Закрыть
}
- {detail.partialErrors.operational && Системная статистика временно недоступна: {detail.partialErrors.operational}
}
+ {status !== 'online' && haproxyEnabled && {status === 'offline' ? t('node.nodeIsNotResponding') : t('node.nodeIsRunningWithLimitations')} {status === 'offline' ? t('node.showingTheLastSavedValues') : t('node.checkTheHaproxyRuntimeBackend')}
}
+ {!haproxyEnabled && {t('node.haproxyIsStopped2')} {t('node.nodeAgentStaysConnectedBut')}
}
+ {haproxyControl && !haproxyControl.supported && {t('node.haproxyControlIsUnavailable')} {t('node.updateNodeAgentToA')}
}
+ {actionError && {actionError} setActionError('')}>{t('nodes.close')}
}
+ {detail.partialErrors.operational && {t('node.systemStatisticsAreTemporarilyUnavailable')} {detail.partialErrors.operational}
}
@@ -497,43 +498,43 @@ export function NodeDetailPage() {
!haproxyBusy && setHAProxyConfirmOpened(false)}
- title="Выключить HAProxy?"
+ title={t('node.stopHaproxy2')}
size="sm"
closeOnClickOutside={!haproxyBusy}
closeOnEscape={!haproxyBusy}
classNames={{ content: 'nf-dialog nf-haproxy-control-dialog', header: 'nf-dialog__header', body: 'nf-dialog__body' }}
>
- Все маршруты этой ноды остановятся. Node Agent останется на связи. HAProxy можно будет снова включить из этого же меню.
- {haproxyControl?.last_error && Последняя ошибка: {haproxyControl.last_error}
}
+ {t('node.allRoutesOnThisNode')} {t('node.nodeAgentWillStayConnected')}
+ {haproxyControl?.last_error && {t('node.lastError')} {haproxyControl.last_error}
}
- setHAProxyConfirmOpened(false)}>Отмена
- } loading={haproxyBusy} onClick={() => { void setHAProxyEnabled(false); }}>Выключить HAProxy
+ setHAProxyConfirmOpened(false)}>{t('node.cancel')}
+ } loading={haproxyBusy} onClick={() => { void setHAProxyEnabled(false); }}>{t('node.stopHaproxy')}
!haproxyBusy && setHAProxyRestartConfirmOpened(false)}
- title="Жёстко перезагрузить HAProxy?"
+ title={t('node.hardRestartHaproxy2')}
size="sm"
closeOnClickOutside={!haproxyBusy}
closeOnEscape={!haproxyBusy}
classNames={{ content: 'nf-dialog nf-haproxy-control-dialog', header: 'nf-dialog__header', body: 'nf-dialog__body' }}
>
- Все активные соединения будут немедленно оборваны. Это именно systemd restart, а не безопасный reload. Клиентам потребуется переподключиться.
- {haproxyControl?.last_error && Последняя ошибка: {haproxyControl.last_error}
}
+ {t('node.allActiveConnectionsWillBe')} {t('node.thisIsASystemdRestart')}
+ {haproxyControl?.last_error && {t('node.lastError')} {haproxyControl.last_error}
}
- setHAProxyRestartConfirmOpened(false)}>Отмена
- } loading={haproxyBusy} onClick={() => { void restartHAProxy(); }}>Жёстко перезагрузить
+ setHAProxyRestartConfirmOpened(false)}>{t('node.cancel')}
+ } loading={haproxyBusy} onClick={() => { void restartHAProxy(); }}>{t('node.hardRestart')}
!nodeEditBusy && setNodeSettingsOpened(false)}
- title="Настройки ноды"
+ title={t('node.nodeSettings')}
size="lg"
closeOnClickOutside={!nodeEditBusy}
closeOnEscape={!nodeEditBusy}
@@ -541,38 +542,38 @@ export function NodeDetailPage() {
>
- setNodeName(event.currentTarget.value)} autoFocus maxLength={200} required />
- setNodeAddress(event.currentTarget.value)} required />
+ setNodeName(event.currentTarget.value)} autoFocus maxLength={200} required />
+ setNodeAddress(event.currentTarget.value)} required />
setNodeHAProxyLogsOn(event.currentTarget.checked)}
disabled={nodeEditBusy}
- label="Логи соединений HAProxy"
- description="Каждое соединение пишется в syslog. На маленьких дисках выключите."
+ label={t('node.haproxyConnectionLogs')}
+ description={t('node.everyConnectionIsWrittenTo')}
/>
HAProxy
- setNodeTuning((current) => ({ ...current, maxconn: String(value) }))} error={nodeTuningTouched && tuningErrors.maxconn} />
- setNodeTuning((current) => ({ ...current, nbthread: String(value) }))} error={nodeTuningTouched && tuningErrors.nbthread} />
+ setNodeTuning((current) => ({ ...current, maxconn: String(value) }))} error={nodeTuningTouched && tuningErrors.maxconn} />
+ setNodeTuning((current) => ({ ...current, nbthread: String(value) }))} error={nodeTuningTouched && tuningErrors.nbthread} />
{ const value = event.currentTarget.value; setNodeTuning((current) => ({ ...current, timeoutConnect: value })); }} onBlur={() => setNodeTuningTouched(true)} error={nodeTuningTouched && tuningErrors.timeoutConnect} />
{ const value = event.currentTarget.value; setNodeTuning((current) => ({ ...current, timeoutClient: value })); }} onBlur={() => setNodeTuningTouched(true)} error={nodeTuningTouched && tuningErrors.timeoutClient} />
{ const value = event.currentTarget.value; setNodeTuning((current) => ({ ...current, timeoutServer: value })); }} onBlur={() => setNodeTuningTouched(true)} error={nodeTuningTouched && tuningErrors.timeoutServer} />
- Пусто = значение по умолчанию. Применяется ко всем маршрутам ноды мягким reload.
+ {t('node.emptyDefaultValueAppliedTo')}
{nodeEditError && {nodeEditError}
}
- } title="Переустановка Node Agent">
+ } title={t('node.reinstallNodeAgent2')}>
- NodeFlow повторно проверит отпечаток SSH-ключа хоста, установит Agent и дождётся нового mTLS-сигнала. ID ноды и маршруты не меняются; при ошибке прежние учётные данные будут автоматически восстановлены.
- } onClick={openReinstall}>Переустановить Agent
+ {t('node.nodeflowWillReVerifyThe')}
+ } onClick={openReinstall}>{t('node.reinstallAgent')}
- setNodeSettingsOpened(false)}>Отмена
- } loading={nodeEditBusy} onClick={saveNode}>Сохранить
+ setNodeSettingsOpened(false)}>{t('node.cancel')}
+ } loading={nodeEditBusy} onClick={saveNode}>{t('node.save')}
@@ -595,17 +596,17 @@ export function NodeDetailPage() {
!nodeDeleteBusy && setNodeDeleteOpened(false)}
- title="Удалить ноду?"
+ title={t('node.deleteNode2')}
closeOnClickOutside={!nodeDeleteBusy}
closeOnEscape={!nodeDeleteBusy}
classNames={{ content: 'nf-dialog', header: 'nf-dialog__header', body: 'nf-dialog__body' }}
>
- Запись {node.name} и связанные данные будут удалены из Panel. Node Agent и HAProxy на сервере останутся установленными.
+ {t('node.theRecord')} {node.name} {t('node.andRelatedDataWillBe')}
Для подтверждения введите {node.name} >}
+ label={<>{t('node.toConfirmType')} {node.name} >}
value={nodeDeleteConfirm}
onChange={(event) => setNodeDeleteConfirm(event.currentTarget.value)}
disabled={nodeDeleteBusy}
@@ -614,8 +615,8 @@ export function NodeDetailPage() {
/>
{nodeDeleteError && {nodeDeleteError}
}
- setNodeDeleteOpened(false)}>Отмена
- } loading={nodeDeleteBusy} disabled={nodeDeleteConfirm !== node.name} onClick={deleteNode}>Удалить ноду
+ setNodeDeleteOpened(false)}>{t('node.cancel')}
+ } loading={nodeDeleteBusy} disabled={nodeDeleteConfirm !== node.name} onClick={deleteNode}>{t('node.deleteNode')}
diff --git a/frontend/src/pages/NodesOverviewPage.tsx b/frontend/src/pages/NodesOverviewPage.tsx
index c8fbe7d..b731367 100644
--- a/frontend/src/pages/NodesOverviewPage.tsx
+++ b/frontend/src/pages/NodesOverviewPage.tsx
@@ -18,6 +18,7 @@ import { TrafficChart } from '../features/nodes/TrafficChart';
import { isNodesOverviewNotFound, useNodesOverview, type TrafficRange } from '../features/nodes/useNodesOverview';
import { api, isUnauthorized } from '../lib/api';
import type { DashboardNode } from '../lib/contracts';
+import { t } from '../i18n';
type StatusFilter = 'all' | 'online' | 'degraded' | 'offline';
const pageSize = 20;
@@ -125,18 +126,18 @@ export function NodesOverviewPage() {
const freshnessWindow = Math.max(45_000, (overview?.traffic_history.bucket_seconds ?? 15) * 2_500);
const historyStale = Boolean(latestSample && (!Number.isFinite(latestSampleAt) || Date.now() - latestSampleAt > freshnessWindow));
const chartStatus: { tone: 'online' | 'degraded' | 'offline' | 'stale'; label: string } = (() => {
- if ((overview && result.isError) || historyStale) return { tone: 'stale', label: 'Данные устарели' };
- if (!latestSample) return { tone: 'offline', label: 'Нет свежих данных' };
- if (!totals.rateComplete) return { tone: 'degraded', label: 'Текущие данные неполные' };
+ if ((overview && result.isError) || historyStale) return { tone: 'stale', label: t('nodes.dataIsStale') };
+ if (!latestSample) return { tone: 'offline', label: t('nodes.noFreshData') };
+ if (!totals.rateComplete) return { tone: 'degraded', label: t('nodes.currentDataIsIncomplete') };
if (selectedScopeNode) {
const selectedState = dashboardState(selectedScopeNode);
- if (selectedState === 'offline') return { tone: 'offline', label: 'Нода недоступна' };
- if (selectedState === 'degraded') return { tone: 'degraded', label: 'Нода деградирует' };
- return { tone: 'online', label: 'Онлайн' };
+ if (selectedState === 'offline') return { tone: 'offline', label: t('nodes.nodeIsUnreachable') };
+ if (selectedState === 'degraded') return { tone: 'degraded', label: t('nodes.nodeIsDegraded') };
+ return { tone: 'online', label: t('nodes.online') };
}
- if (statusCounts.online === 0 && statusCounts.degraded === 0) return { tone: 'offline', label: 'Ноды недоступны' };
- if (statusCounts.degraded > 0 || statusCounts.offline > 0) return { tone: 'degraded', label: 'Частично доступно' };
- return { tone: 'online', label: 'Онлайн' };
+ if (statusCounts.online === 0 && statusCounts.degraded === 0) return { tone: 'offline', label: t('nodes.nodesAreUnreachable') };
+ if (statusCounts.degraded > 0 || statusCounts.offline > 0) return { tone: 'degraded', label: t('nodes.partiallyAvailable') };
+ return { tone: 'online', label: t('nodes.online') };
})();
const changeFilter = (value: StatusFilter) => { setStatus(value); setPage(1); };
@@ -164,7 +165,7 @@ export function NodesOverviewPage() {
await result.refetch();
} catch (error) {
setNodeOrder(previous);
- setOrderError(error instanceof Error ? error.message : 'Порядок нод не сохранён');
+ setOrderError(error instanceof Error ? error.message : t('nodes.nodeOrderNotSaved'));
void result.refetch();
} finally {
setOrderingNodeID('');
@@ -182,13 +183,13 @@ export function NodesOverviewPage() {
return (
- { setQuery(event.currentTarget.value); setPage(1); }} leftSection={ } aria-label="Поиск нод" />
+ { setQuery(event.currentTarget.value); setPage(1); }} leftSection={ } aria-label={t('nodes.searchNodes2')} />
- result.refetch()} loading={result.isFetching} aria-label="Обновить">
- navigate('/settings')} aria-label="Настройки">
- } onClick={() => setDialogOpened(true)}>Добавить ноду
+ result.refetch()} loading={result.isFetching} aria-label={t('nodes.refresh')}>
+ navigate('/settings')} aria-label={t('app.settings')}>
+ } onClick={() => setDialogOpened(true)}>{t('nodes.addNode')}
>}
/>
@@ -197,24 +198,24 @@ export function NodesOverviewPage() {
mb="sm"
color="yellow"
icon={ }
- title="Источник сброшен"
+ title={t('nodes.sourceReset')}
role="status"
withCloseButton
- closeButtonLabel="Скрыть уведомление"
+ closeButtonLabel={t('nodes.dismissNotice')}
onClose={() => setRecoveredScope(false)}
>
- Выбранная нода больше недоступна. Показана сводка всех нод.
+ {t('nodes.theSelectedNodeIsNo')}
)}
{result.isPending && !overview ? : result.isError && !selectedScopeInvalid && !overview ? (
- result.refetch()} />} />
+ result.refetch()} />} />
) : !nodes.length ? (
- } onClick={() => setDialogOpened(true)}>Добавить ноду} />
+ } onClick={() => setDialogOpened(true)}>{t('nodes.addNode')}} />
) : (
-
{ setRecoveredScope(false); setScope(value ?? 'all'); }} data={[{ value: 'all', label: 'Все ноды' }, ...nodes.map(({ node }) => ({ value: node.id, label: node.name }))]} allowDeselect={false} aria-label="Источник трафика" />}>
+ { setRecoveredScope(false); setScope(value ?? 'all'); }} data={[{ value: 'all', label: t('nodes.allNodes') }, ...nodes.map(({ node }) => ({ value: node.id, label: node.name }))]} allowDeselect={false} aria-label={t('nodes.trafficSource')} />}>
@@ -227,21 +228,21 @@ export function NodesOverviewPage() {
className="nf-status-tabs"
value={status}
onChange={changeFilter}
- label="Фильтр состояния"
+ label={t('nodes.statusFilter')}
items={[
- { value: 'all', label: 'Все ноды', count: nodes.length },
- { value: 'online', label: 'Работают', count: statusCounts.online },
- { value: 'degraded', label: 'Деградируют', count: statusCounts.degraded },
- { value: 'offline', label: 'Недоступны', count: statusCounts.offline },
+ { value: 'all', label: t('nodes.allNodes'), count: nodes.length },
+ { value: 'online', label: t('nodes.online2'), count: statusCounts.online },
+ { value: 'degraded', label: t('nodes.degraded'), count: statusCounts.degraded },
+ { value: 'offline', label: t('nodes.unreachable'), count: statusCounts.offline },
]}
/>
-
{ setQuery(event.currentTarget.value); setPage(1); }} leftSection={ } aria-label="Поиск в таблице" />
+ { setQuery(event.currentTarget.value); setPage(1); }} leftSection={ } aria-label={t('nodes.searchInTable')} />
- setRelaxedRows((value) => !value)} aria-label="Переключить плотность строк" aria-pressed={relaxedRows}>
+ setRelaxedRows((value) => !value)} aria-label={t('nodes.toggleRowDensity')} aria-pressed={relaxedRows}>
- {orderError &&
{orderError} setOrderError('')}>Закрыть
}
+ {orderError &&
{orderError} setOrderError('')}>{t('nodes.close')}
}
{filtered.length ? (
- ) :
}
+ ) :
}
)}
@@ -267,11 +268,11 @@ function StatusMenu({ value, onChange, compact = false }: { value: StatusFilter;
return (
- } rightSection={ }>Фильтры
+ } rightSection={ }>{t('nodes.filters')}
- Состояние ноды
- {([['all', 'Все ноды'], ['online', 'Работают'], ['degraded', 'Деградируют'], ['offline', 'Недоступны']] as const).map(([status, label]) => (
+ {t('nodes.nodeStatus')}
+ {([['all', t('nodes.allNodes')], ['online', t('nodes.online2')], ['degraded', t('nodes.degraded')], ['offline', t('nodes.unreachable')]] as const).map(([status, label]) => (
onChange(status)}>{label}
))}
diff --git a/frontend/src/pages/RouteEditorPage.tsx b/frontend/src/pages/RouteEditorPage.tsx
index 1d9773d..e284436 100644
--- a/frontend/src/pages/RouteEditorPage.tsx
+++ b/frontend/src/pages/RouteEditorPage.tsx
@@ -33,16 +33,17 @@ import {
} from '../features/routes/model';
import {
BlockHead, EditorSection, Note, OptionList, OptionRow, SettingRow, SettingsDivider, SettingsGrid, SettingsNote,
- UnitNumberInput, plural,
+ UnitNumberInput,
} from '../features/routes/editor-ui';
import { APIError, api, isUnauthorized, resolveDNS } from '../lib/api';
import type { NodeOperational, NodeRecord, RouteRecord, DNSResolveResult } from '../lib/contracts';
import './route-editor.css';
+import { t } from '../i18n';
interface EditorData { node: NodeRecord; routes: RouteRecord[]; route?: RouteRecord; agentVersion?: string; memoryTotalBytes?: number }
const KERNEL_SHAPER_AGENT_CODE = 'kernel_shaper_requires_agent_1_1';
-const kernelShaperAgentHint = `Ядерный шейпер требует Node Agent ${KERNEL_SHAPER_MIN_AGENT} на этой ноде`;
+const kernelShaperAgentHint = t('editor.theKernelShaperRequiresNode', { KERNEL_SHAPER_MIN_AGENT });
type SaveIntent = 'draft' | 'enable' | 'apply';
const explicitDemo = new URLSearchParams(window.location.search).get('demo') === '1'
@@ -53,8 +54,8 @@ function errorText(errors: RouteDraftError[], fields: RouteDraftError['field'][]
}
const MISSING_FIELD_LABELS: Partial> = {
- name: 'имя', snis: 'SNI', listenerPort: 'порт', listenerIP: 'IP ноды', listener: 'порт',
- quota: 'лимит трафика', clientUploadMbps: 'скорость', clientDownloadMbps: 'скорость', bandwidth: 'скорость',
+ name: t('editor.name'), snis: 'SNI', listenerPort: t('editor.port'), listenerIP: t('editor.nodeIp'), listener: t('editor.port'),
+ quota: t('editor.trafficLimit'), clientUploadMbps: t('editor.speed'), clientDownloadMbps: t('editor.speed'), bandwidth: t('editor.speed'),
};
/** Neutral «what is left» line for an untouched form: «Заполните имя, SNI и адрес сервера». */
@@ -62,19 +63,19 @@ function missingFieldsHint(errors: RouteDraftError[]): string {
const parts: string[] = [];
for (const error of errors) {
const label = error.field === 'servers'
- ? (/адрес|путь/i.test(error.message) ? 'адрес сервера' : 'серверы')
+ ? (/адрес|путь|address|path/i.test(error.message) ? t('editor.serverAddress') : t('editor.servers'))
: MISSING_FIELD_LABELS[error.field];
if (label && !parts.includes(label)) parts.push(label);
}
- if (!parts.length) return 'Проверьте поля формы';
- const list = parts.length > 1 ? `${parts.slice(0, -1).join(', ')} и ${parts.at(-1)}` : parts[0];
- return `Заполните ${list}`;
+ if (!parts.length) return t('editor.checkTheFormFields');
+ const list = parts.length > 1 ? t('editor.p0AndP1', { p0: parts.slice(0, -1).join(', '), p1: parts.at(-1) }) : parts[0];
+ return t('editor.fillInList', { list });
}
function statusCopy(intent: SaveIntent, editing: boolean) {
- if (intent === 'draft') return editing ? 'Черновик сохранён' : 'Маршрут создан как выключенный черновик';
- if (intent === 'apply') return 'Изменения отправлены на ноду';
- return 'Маршрут создан и отправлен на включение';
+ if (intent === 'draft') return editing ? t('editor.draftSaved') : t('editor.routeCreatedAsADisabled');
+ if (intent === 'apply') return t('editor.changesSentToTheNode');
+ return t('editor.routeCreatedAndSentFor');
}
async function loadEditor(nodeID: string, routeID?: string): Promise {
@@ -151,12 +152,12 @@ function useResolver(hostValue: string) {
const result: DNSResolveResult = await resolveHost(host);
setState({
host, loading: false, done: true, addresses: result.addresses ?? [],
- error: result.addresses?.length ? '' : (result.error || 'Домен не вернул ни одного адреса.'),
+ error: result.addresses?.length ? '' : (result.error || t('editor.theDomainDidNotResolve')),
});
} catch (error) {
const message = error instanceof APIError && error.code === 'invalid_host'
- ? 'Некорректное имя домена.'
- : error instanceof Error ? error.message : 'Не удалось получить адреса.';
+ ? t('editor.invalidDomainName')
+ : error instanceof Error ? error.message : t('editor.failedToResolveAddresses');
setState({ ...idleResolve, host, done: true, error: message });
}
};
@@ -165,10 +166,10 @@ function useResolver(hostValue: string) {
function ResolveButton({ canResolve, loading, disabled = false, onClick }: { canResolve: boolean; loading: boolean; disabled?: boolean; onClick: () => void }) {
return (
-
+
} onClick={onClick} loading={loading} disabled={!canResolve || disabled}>
- IP домена
+ {t('editor.domainIps')}
@@ -187,11 +188,11 @@ function PreferredIPLine({ server, hasOtherServers, error, onChange }: {
return (
- Основной IP
+ {t('editor.preferredIp')}
{hasOtherServers
- ? 'Весь трафик на этот IP; если он недоступен — на все IP домена и резервные серверы.'
- : 'Весь трафик на этот IP; если он недоступен — на все IP домена.'}
+ ? t('editor.allTrafficGoesToThis')
+ : t('editor.allTrafficGoesToThis2')}
@@ -199,7 +200,7 @@ function PreferredIPLine({ server, hasOtherServers, error, onChange }: {
id={id}
size="sm"
className="nf-srv-sub__ip"
- placeholder="не задан"
+ placeholder={t('editor.notSet')}
value={server.preferredIP}
onChange={(e) => onChange(e.currentTarget.value)}
error={invalid || error ? true : undefined}
@@ -207,13 +208,13 @@ function PreferredIPLine({ server, hasOtherServers, error, onChange }: {
/>
void run()} />
- {(invalid || error) &&
{invalid ? 'Некорректный IP-адрес.' : error}
}
+ {(invalid || error) &&
{invalid ? t('editor.invalidIpAddress') : error}
}
{current.done && (
current.error
?
{current.error}
: (
<>
-
+
{current.addresses.map((address) => {
const selected = address === value;
return (
@@ -224,7 +225,7 @@ function PreferredIPLine({ server, hasOtherServers, error, onChange }: {
);
})}
-
Адреса получены с хоста панели — нода может видеть другие.
+
{t('editor.addressesWereResolvedOnThe')}
>
)
)}
@@ -232,11 +233,11 @@ function PreferredIPLine({ server, hasOtherServers, error, onChange }: {
);
}
-const IP_WEIGHT_HELP = 'Доля новых клиентов для этого IP: 2 — вдвое больше, чем у IP с весом 1. Пусто — вес сервера (по умолчанию 1).';
-const IP_COST_PING_HELP = 'Множитель задержки этого IP: 2 — IP кажется вдвое медленнее и выбирается реже. Пусто — стоимость сервера (по умолчанию 1).';
-const IP_SHARE_CONN_NOTE = 'Новые клиенты делятся пропорционально весу: 4 ядра против 2 — вес 2 у 4-ядерного, и он получит вдвое больше клиентов. При «Запоминании» уже закреплённые клиенты остаются на своём IP.';
-const IP_SHARE_PING_NOTE = 'Чем меньше задержка × стоимость, тем больше клиентов получает IP; вес задаёт долю при равной задержке. Под мощность сервера: 4 ядра против 2 — вес 2 у 4-ядерного.';
-const IP_SHARE_NOTE = 'Доля IP пропорциональна весу. Например, 4 ядра против 2: вес 2 у 4-ядерного — он получит вдвое больше клиентов.';
+const IP_WEIGHT_HELP = t('editor.shareOfNewClientsFor');
+const IP_COST_PING_HELP = t('editor.latencyMultiplierForThisIp');
+const IP_SHARE_CONN_NOTE = t('editor.newClientsAreSplitIn');
+const IP_SHARE_PING_NOTE = t('editor.theLowerLatencyCostThe');
+const IP_SHARE_NOTE = t('editor.anIpSShareIs');
interface IPWeightsEditorProps {
server: ServerDraft;
@@ -278,45 +279,45 @@ function IPWeightsEditor({ server, agentAllowed, showCost, connections, weightsF
return (
- {showCost ? 'Вес и стоимость отдельных IP' : 'Вес отдельных IP'}
- Пусто — как у сервера.{weightsFixed ? ' Сейчас веса фиксированы и не применятся.' : ''}
+ {showCost ? t('editor.perIpWeightAndCost') : t('editor.perIpWeight')}
+ {t('editor.emptySameAsTheServer')}{weightsFixed ? ` ${t('editor.weightsAreCurrentlyFixedAnd')}` : ''}
- {!agentAllowed &&
Веса отдельных IP применяет агент ноды — обновите Node Agent до {KERNEL_SHAPER_MIN_AGENT} или новее.
}
+ {!agentAllowed &&
{t('editor.perIpWeightsAreApplied', { version: KERNEL_SHAPER_MIN_AGENT })}
}
{server.ipWeights.length > 0 && !weightsFixed && (
{connections ? IP_SHARE_CONN_NOTE : showCost ? IP_SHARE_PING_NOTE : IP_SHARE_NOTE}
)}
{server.ipWeights.length > 0 && (
// Cost column only for «Меньше задержка»; elsewhere the weight alone sets the share.
-
+
IP
- Вес
+ {t('editor.weight')}
{showCost && (
- Стоимость
+ {t('editor.cost')}
)}
{/* Stays in grid flow: a hidden (absolute) cell would shift every row by one column. */}
-
+
{server.ipWeights.map((w) => (
{w.ip}
- patch(w.ip, { weight: v === '' ? '' : Number(v) })} aria-label={`Вес ${w.ip}`} />
+ patch(w.ip, { weight: v === '' ? '' : Number(v) })} aria-label={t('editor.weightIp', { ip: w.ip })} />
{showCost &&
- patch(w.ip, { cost: v === '' ? '' : Number(v) })} aria-label={`Стоимость ${w.ip}`} />
+ patch(w.ip, { cost: v === '' ? '' : Number(v) })} aria-label={t('editor.costIp', { ip: w.ip })} />
}
- removeIP(w.ip)} aria-label={`Убрать ${w.ip}`}>
+ removeIP(w.ip)} aria-label={t('editor.removeIp', { ip: w.ip })}>
))}
@@ -326,27 +327,27 @@ function IPWeightsEditor({ server, agentAllowed, showCost, connections, weightsF
setManual(e.currentTarget.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addManual(); } }}
error={manualInvalid || manualDuplicate ? true : undefined}
- aria-label={`Добавить IP для ${label}`}
+ aria-label={t('editor.addIpForLabel', { label })}
disabled={full}
/>
} onClick={addManual} disabled={!manualValue || manualInvalid || manualDuplicate || full}>
- Добавить
+ {t('editor.add')}
void run()} />
- {(manualInvalid || manualDuplicate) &&
{manualInvalid ? 'Некорректный IP-адрес.' : 'Этот IP уже в списке.'}
}
+ {(manualInvalid || manualDuplicate) &&
{manualInvalid ? t('editor.invalidIpAddress') : t('editor.thisIpIsAlreadyIn')}
}
{error &&
{error}
}
{current.done && (
current.error
?
{current.error}
: (
-
+
{current.addresses.map((address) => (
addIP(address)}>
{known.has(address) ? : }
@@ -384,15 +385,16 @@ const SERVER_COLUMN_TRACKS: Record = {
* over the model's messages; anything unrecognised stays a row-level message.
*/
function cellForMessage(text: string): ServerCell {
- if (/^имя/i.test(text)) return 'name';
- if (text.includes('основной IP')) return 'preferred';
- if (/(вес|стоимость) IP|отдельных IP|IP не должны|IP в списке|вес и стоимость IP/.test(text)) return 'ipweights';
- if (text.includes('DNS-пул')) return 'dns';
- if (/адрес|IPv4/.test(text)) return 'host';
+ // Matches both the ru and en wording of the model's messages.
+ if (/^(имя|name|enter a name)/i.test(text)) return 'name';
+ if (text.includes(t('editor.preferredIp2'))) return 'preferred';
+ if (/(вес|стоимость) IP|отдельных IP|IP не должны|IP в списке|вес и стоимость IP|per-IP|IPs must not repeat/.test(text)) return 'ipweights';
+ if (text.includes(t('editor.dnsPool'))) return 'dns';
+ if (/адрес|address|IPv4/.test(text)) return 'host';
if (text.includes('Unix')) return 'unix';
- if (text.includes('порт')) return 'port';
- if (text.startsWith('вес')) return 'weight';
- if (text.startsWith('стоимость')) return 'cost';
+ if (text.includes(t('editor.port'))) return 'port';
+ if (text.startsWith(t('editor.weight2'))) return 'weight';
+ if (text.startsWith(t('editor.cost2'))) return 'cost';
return 'row';
}
@@ -400,7 +402,7 @@ function splitServerErrors(messages: string[]) {
const perServer = new Map();
const general: string[] = [];
for (const message of messages) {
- const match = /^Сервер (\d+): (.+)$/.exec(message);
+ const match = /^(?:Сервер|Server) (\d+): (.+)$/.exec(message);
if (!match) { general.push(message); continue; }
const index = Number(match[1]) - 1;
const text = match[2];
@@ -442,7 +444,7 @@ function ServersEditor({ servers, balanceMode: rawBalanceMode, poolSettings, onC
'--srv-sub-start': String(col('name')),
} as CSSProperties;
const weightOn = Boolean(poolSettings);
- const offHint = 'Нужно два сервера или DNS-пул в режиме «Пул»';
+ const offHint = t('editor.twoServersOrADns');
const update = (index: number, patch: Partial) => {
onChange(servers.map((s, i) => i === index ? { ...s, ...patch } : s));
@@ -468,25 +470,25 @@ function ServersEditor({ servers, balanceMode: rawBalanceMode, poolSettings, onC
-
Имя
-
Тип
-
Адрес
-
Порт
+
{t('editor.name2')}
+
{t('editor.type')}
+
{t('editor.address')}
+
{t('editor.port2')}
-
- DNS-пул
+
+ {t('editor.dnsPool')}
- {failover ?
Роль : <>
+ {failover ?
{t('editor.role')} : <>
-
- Вес
+
+ {t('editor.weight')}
{costColumn && (
-
- Стоимость
+
+ {t('editor.cost')}
)}
@@ -496,7 +498,7 @@ function ServersEditor({ servers, balanceMode: rawBalanceMode, poolSettings, onC
{servers.map((server, index) => {
const hostIsIP = server.host.trim() !== '' && isIPAddress(server.host);
const tcp = server.targetType === 'tcp';
- const label = server.name.trim() || `сервер ${index + 1}`;
+ const label = server.name.trim() || t('editor.serverP0', { p0: index + 1 });
const backup = serverIsBackup(draftView, index);
const cellErrors = perServer.get(index) ?? [];
const errorFor = (cell: ServerCell) => cellErrors.find((e) => e.cell === cell)?.message;
@@ -507,7 +509,7 @@ function ServersEditor({ servers, balanceMode: rawBalanceMode, poolSettings, onC
update(index, { targetType: (v ?? 'tcp') as 'tcp' | 'unix', dnsPool: false, preferredIP: '' })}
allowDeselect={false}
- data={[{ value: 'tcp', label: 'IP / домен' }, { value: 'unix', label: 'Unix socket' }]}
+ data={[{ value: 'tcp', label: t('editor.ipDomain') }, { value: 'unix', label: 'Unix socket' }]}
/>
{tcp ? (
<>
update(index, { host: e.currentTarget.value })}
error={errorFor('host') ? true : undefined}
/>
update(index, { port: v === '' ? '' : Number(v) })}
min={1} max={65535} allowDecimal={false} hideControls inputMode="numeric"
error={errorFor('port') ? true : undefined}
/>
-
+
update(index, { dnsPool: e.currentTarget.checked, preferredIP: e.currentTarget.checked ? server.preferredIP : '' })}
/>
- DNS-пул
+ {t('editor.dnsPool')}
>
) : (
update(index, { unixSocketPath: e.currentTarget.value })}
@@ -567,14 +569,14 @@ function ServersEditor({ servers, balanceMode: rawBalanceMode, poolSettings, onC
{failover ? (
- {index + 1} {backup ? 'Резерв' : 'Основной'}
+ {index + 1} {backup ? t('editor.backup') : t('editor.primary')}
) : <>
update(index, { weight: v === '' ? '' : Number(v) })}
@@ -587,7 +589,7 @@ function ServersEditor({ servers, balanceMode: rawBalanceMode, poolSettings, onC
{costColumn && (
update(index, { cost: v === '' ? '' : Number(v) })}
@@ -600,23 +602,23 @@ function ServersEditor({ servers, balanceMode: rawBalanceMode, poolSettings, onC
-
+
{failover && (
<>
- } disabled={index === 0} onClick={() => move(index, -1)}>Выше
- } disabled={index === servers.length - 1} onClick={() => move(index, 1)}>Ниже
+ } disabled={index === 0} onClick={() => move(index, -1)}>{t('editor.up')}
+ } disabled={index === servers.length - 1} onClick={() => move(index, 1)}>{t('editor.down')}
} onClick={() => setRole(index, !server.backup)}>
- {server.backup ? 'Сделать основным' : 'Сделать резервным'}
+ {server.backup ? t('editor.makePrimary') : t('editor.makeBackup')}
>
)}
} disabled={!canRemove} onClick={() => remove(index)}>
- Удалить сервер
+ {t('editor.removeServer')}
@@ -666,9 +668,9 @@ function ServersEditor({ servers, balanceMode: rawBalanceMode, poolSettings, onC
// New servers join as reserves; in pool mode the flag is ignored.
onClick={() => onChange([...servers, { ...emptyServerDraft(nextServerName(servers)), backup: servers.length > 0 }])}
>
- Добавить сервер
+ {t('editor.addServer')}
- {maxReached &&
Максимум — 16 серверов. }
+ {maxReached &&
{t('editor.maximum16Servers')} }
);
@@ -680,23 +682,23 @@ function balanceExplanation(draft: RouteDraft): string {
const single = servers.length === 1;
if (effectiveBalanceMode(draft) === 'pool') {
return single
- ? 'Нагрузка делится между всеми IP домена.'
- : 'Нагрузка делится между всеми серверами и всеми IP доменов с DNS-пулом.';
+ ? t('editor.loadIsSplitAcrossAll')
+ : t('editor.loadIsSplitAcrossAll2');
}
const preferred = preferredIPApplies(draft, 0) && servers[0].preferredIP.trim() !== '';
if (single) {
return preferred
- ? 'Трафик идёт на основной IP; если он упал — делится между всеми IP домена.'
- : 'Задайте основной IP — он получит весь трафик, остальные IP домена станут резервом.';
+ ? t('editor.trafficGoesToThePreferred')
+ : t('editor.setAPreferredIpIt');
}
// Renderer emits `option allbackups` when a DNS pool is a backup or the primary has a preferred IP.
const allBackups = preferred || servers.some((s, i) => serverIsBackup(draft, i) && s.targetType === 'tcp' && s.dnsPool);
const primaries = servers.filter((_, i) => !serverIsBackup(draft, i)).length;
- const head = primaries > 1 ? 'Трафик делится между основными серверами' : 'Трафик идёт на основной сервер';
- const fallen = primaries > 1 ? 'если все основные упали' : 'если он упал';
+ const head = primaries > 1 ? t('editor.trafficIsSplitAcrossThe') : t('editor.trafficGoesToThePrimary');
+ const fallen = primaries > 1 ? t('editor.ifAllPrimariesAreDown') : t('editor.ifItIsDown');
return allBackups
- ? `${head}; ${fallen} — делится между всеми резервными сразу.`
- : `${head}; резервные включаются по порядку, ${fallen}.`;
+ ? t('editor.headFallenItIsSplit', { head, fallen })
+ : t('editor.headBackupsTakeOverIn', { head, fallen });
}
// ─── «Распределение клиентов» ───────────────────────────────────────────────────
@@ -730,8 +732,8 @@ function DistributionBlock({ draft, update, agent110, agentVersion, autoTableEnt
o.value === family)?.description}
+ label={choice === 'table' ? t('editor.firstServer') : t('editor.algorithm')}
+ hint={choice === 'table' ? t('editor.howToPickAServer') : balanceAlgorithmOptions.find((o) => o.value === family)?.description}
>
{family === 'roundrobin' && (
-
+
)}
{family === 'random' && (
-
+
update('balanceRandomDraws', v)}
placeholder="2" min={1} max={5} allowDecimal={false} inputMode="numeric"
@@ -765,7 +767,7 @@ function DistributionBlock({ draft, update, agent110, agentVersion, autoTableEnt
)}
{family === 'leastconn' && (
<>
-
+
update('leastpingTolerancePct', v)}
placeholder="0" min={0} max={100} allowDecimal={false} inputMode="numeric"
@@ -773,28 +775,28 @@ function DistributionBlock({ draft, update, agent110, agentVersion, autoTableEnt
/>
- {effectiveBalanceMode(draft) === 'failover' ? 'С резервными серверами используется обычный leastconn: оставьте толерантность 0 %.' : 'Вес — в таблице серверов. Толерантность больше 0 применяет агент ноды каждые 5 с.'}{effectiveBalanceMode(draft) === 'pool' && !leastconnToleranceSupported(agentVersion) ? ` Нужен Node Agent ${LEASTCONN_TOLERANCE_MIN_AGENT}, на ноде ${agentVersion ?? 'неизвестно'}.` : ''}
+ {effectiveBalanceMode(draft) === 'failover' ? t('editor.withBackupServersPlainLeastconn') : t('editor.weightIsSetInThe')}{effectiveBalanceMode(draft) === 'pool' && !leastconnToleranceSupported(agentVersion) ? t('editor.requiresNodeAgentLeastconnTolerance', { LEASTCONN_TOLERANCE_MIN_AGENT, p1: agentVersion ?? t('editor.unknown') }) : ''}
>
)}
{family === 'leastping' && (
<>
-
+
update('leastpingTolerancePct', v)}
placeholder={String(DEFAULT_LEASTPING_TOLERANCE_PCT)} min={0} max={100} allowDecimal={false} inputMode="numeric"
error={showError(['leastpingTolerancePct']) ? true : undefined} aria-describedby="dist-tolerance-hint"
/>
-
+
update('leastpingToleranceMs', v)}
- placeholder="выкл" min={0} max={1000} allowDecimal={false} inputMode="numeric"
+ id="dist-floor" unit={t('editor.ms')} value={draft.leastpingToleranceMs} onValue={(v) => update('leastpingToleranceMs', v)}
+ placeholder={t('editor.off')} min={0} max={1000} allowDecimal={false} inputMode="numeric"
error={showError(['leastpingToleranceMs']) ? true : undefined} aria-describedby="dist-floor-hint"
/>
- Задержку меряет проверка здоровья, агент ноды обновляет веса каждые 5 с. Стоимость — в таблице серверов.{!agent110 ? ` Нужен Node Agent ${KERNEL_SHAPER_MIN_AGENT}, на ноде ${agentVersion ?? 'неизвестно'}.` : ''}
+ {t('editor.latencyIsMeasuredByThe')}{!agent110 ? t('editor.requiresNodeAgentKernelShaper', { KERNEL_SHAPER_MIN_AGENT, p1: agentVersion ?? t('editor.unknown') }) : ''}
>
)}
@@ -803,19 +805,19 @@ function DistributionBlock({ draft, update, agent110, agentVersion, autoTableEnt
const ipv6Rows = (
<>
-
+
update('clientIPv6', value === 'on')}
- data={[{ value: 'on', label: 'Вкл' }, { value: 'off', label: 'Выкл' }]}
+ data={[{ value: 'on', label: t('editor.on') }, { value: 'off', label: t('editor.off2') }]}
aria-labelledby="dist-ipv6-clients-label"
aria-describedby="dist-ipv6-clients-hint"
/>
{/* Always in its slot (inactive without IPv6): the rows after it never move. */}
-
+
update('stickyIPv6Prefix', v === '' || (choice === 'hash' && v === 128) ? 0 : v)}
@@ -829,7 +831,7 @@ function DistributionBlock({ draft, update, agent110, agentVersion, autoTableEnt
return (
-
+
{distributionChoiceOptions.map((option) => {
const selected = option.value === choice;
@@ -853,22 +855,22 @@ function DistributionBlock({ draft, update, agent110, agentVersion, autoTableEnt
{choice === 'algorithm' && algorithmRows}
{choice === 'hash' && (
<>
-
+
update('stickyHash', value === 'even' ? 'map-based' : '')}
- data={[{ value: 'stable', label: 'Стабильное' }, { value: 'even', label: 'Ровное' }]}
+ data={[{ value: 'stable', label: t('editor.stable') }, { value: 'even', label: t('editor.even') }]}
aria-labelledby="dist-hash-label"
aria-describedby="dist-hash-hint"
/>
{/* «Ровное» hash ignores the limit: the row stays, disabled. */}
-
+
update('stickyHashBalanceFactor', v)}
- placeholder="выкл" min={101} max={1000} allowDecimal={false} inputMode="numeric"
+ placeholder={t('editor.off')} min={101} max={1000} allowDecimal={false} inputMode="numeric"
disabled={draft.stickyHash === 'map-based'}
error={draft.stickyHash !== 'map-based' && showError(['stickyHashBalanceFactor']) ? true : undefined} aria-describedby="dist-factor-hint"
/>
@@ -878,7 +880,7 @@ function DistributionBlock({ draft, update, agent110, agentVersion, autoTableEnt
)}
{choice === 'table' && (
<>
-
+
{ if (unit && ttl.amount !== '') update('stickyTTL', `${ttl.amount}${unit}`); }}
allowDeselect={false}
data={(ttl.unit === 's' ? ['s', 'm', 'h'] as const : ['m', 'h'] as const).map((unit) => ({ value: unit, label: ttlUnitLabels[unit] }))}
- aria-label="Единица времени"
+ aria-label={t('editor.timeUnit')}
/>
-
+
{stickyTTLQuickPicks.map((pick) => {
const selected = (draft.stickyTTL || DEFAULT_STICKY_TTL) === pick;
return (
@@ -912,9 +914,9 @@ function DistributionBlock({ draft, update, agent110, agentVersion, autoTableEnt
wide
stableHint
group={tableAuto}
- label="Клиентов в памяти"
- hint="Память занимается по мере прихода клиентов; запись удаляется через «Помнить» без соединений. Когда места нет, самые старые забываются."
- aside={tableAuto ? autoTableHint(autoTableEntries, memoryKnown) : memory ? `${memory} при заполнении` : undefined}
+ label={t('editor.clientsInMemory')}
+ hint={t('editor.memoryIsUsedAsClients')}
+ aside={tableAuto ? autoTableHint(autoTableEntries, memoryKnown) : memory ? t('editor.memoryWhenFull', { memory }) : undefined}
error={tableError}
>
update('stickyTableEntries', value === 'auto' ? '' : tableEntriesToken(tableEntriesCount(autoTableEntries)))}
- data={[{ value: 'auto', label: 'Авто' }, { value: 'manual', label: 'Задать' }]}
+ data={[{ value: 'auto', label: t('node.auto2') }, { value: 'manual', label: t('editor.set') }]}
aria-labelledby="dist-table-label"
/>
{/* Always rendered: «Авто» shows the computed size, disabled. */}
{ if (v !== '') update('stickyTableEntries', tableEntriesToken(v)); }}
min={1} max={TABLE_MAX_CLIENTS} allowDecimal={false} inputMode="numeric" thousandSeparator=" "
disabled={tableAuto}
@@ -941,8 +943,8 @@ function DistributionBlock({ draft, update, agent110, agentVersion, autoTableEnt
)}
{choice !== 'algorithm' && (
- {choice === 'table' ? 'Таблица переживает перезагрузку HAProxy. ' : ''}IP клиента берётся из PROXY protocol, если он приходит, иначе — адрес соединения.
- {choice === 'table' && effectiveBalanceMode(draft) === 'failover' ? ' Клиент, попавший на резерв, остаётся на нём до истечения «Помнить», даже когда основные серверы вернулись.' : ''}
+ {choice === 'table' ? `${t('editor.theTableSurvivesHaproxyReloads')} ` : ''}{t('editor.clientIpFromProxy')}
+ {choice === 'table' && effectiveBalanceMode(draft) === 'failover' ? ` ${t('editor.aClientThatLandedOn')}` : ''}
)}
@@ -996,9 +998,9 @@ export function RouteEditorPage() {
nextDraft = templateDraft(template); setLoadedTemplate(template);
} else if (copyNode && copyRoute) {
const source = await loadEditor(copyNode, copyRoute);
- if (!source.route) throw new Error('Исходный маршрут не найден');
+ if (!source.route) throw new Error(t('editor.sourceRouteNotFound'));
nextDraft = routeToDraft(source.route);
- nextDraft.name = `${nextDraft.name.slice(0, 72)} — копия`;
+ nextDraft.name = t('editor.p0Copy', { p0: nextDraft.name.slice(0, 72) });
}
}
setData(value); setDraft(nextDraft); setExpertEnabled(Boolean(nextDraft.expertOverride));
@@ -1131,16 +1133,16 @@ export function RouteEditorPage() {
setDraft(cleanDraft); setInitialSignature(JSON.stringify(cleanDraft)); setSavedMessage(statusCopy(intent, editing));
if (!editing && !anchoredCreatedDraft) { bypassNavigationRef.current = true; navigate(editURL(result.id), { replace: true }); }
} catch (error) {
- const message = error instanceof Error ? error.message : 'Маршрут не сохранён';
+ const message = error instanceof Error ? error.message : t('editor.routeNotSaved');
const kernelAgent = error instanceof APIError && error.code === KERNEL_SHAPER_AGENT_CODE;
if (kernelAgent) {
- setShaperError(`${kernelShaperAgentHint}. Выберите HAProxy или обновите агент.`);
+ setShaperError(t('editor.kernelshaperagenthintChooseHaproxyOrUpdate', { kernelShaperAgentHint }));
setAdditionalOpen(true);
}
const detail = kernelAgent
? `${kernelShaperAgentHint}.`
- : message.includes('version') ? 'Маршрут изменился в другой вкладке. Обновите данные и повторите.' : message;
- setSaveError(anchoredCreatedDraft ? `Черновик сохранён, но включить маршрут не удалось: ${detail}` : detail);
+ : message.includes('version') ? t('editor.theRouteWasChangedIn') : message;
+ setSaveError(anchoredCreatedDraft ? t('editor.draftSavedButTheRoute', { detail }) : detail);
} finally { setSaving(null); }
};
@@ -1155,9 +1157,9 @@ export function RouteEditorPage() {
blocker.proceed();
};
- if (loading) return Загружаем маршруты ноды и проверяем конфликты портов…
;
+ if (loading) return {t('editor.loadingNodeRoutesAndChecking')}
;
if (loadError && isUnauthorized(loadError)) return ;
- if (loadError || !data) return navigate(`/nodes${query}`)}>К нодам Повторить >} /> ;
+ if (loadError || !data) return navigate(`/nodes${query}`)}>{t('editor.backToNodes')} {t('app.retry')} >} /> ;
const showError = (fields: RouteDraftError['field'][]) => submitAttempted ? errorText(errors, fields) : undefined;
const serverErrors = submitAttempted ? errors.filter((e) => e.field === 'servers').map((e) => e.message) : [];
@@ -1182,7 +1184,7 @@ export function RouteEditorPage() {
const kernelAllowed = agent110;
const shaperIsError = Boolean(shaperError) || (!kernelAllowed && draft.shaperMode === 'kernel');
const shaperHint = shaperError
- || (!kernelAllowed ? `${kernelShaperAgentHint} (сейчас ${data.agentVersion}).${draft.shaperMode === 'kernel' ? ' Выберите HAProxy.' : ''}` : '');
+ || (!kernelAllowed ? t('editor.kernelshaperagenthintCurrentlyAgentversionP2', { kernelShaperAgentHint, agentVersion: data.agentVersion, p2: draft.shaperMode === 'kernel' ? t('misc.chooseHaproxy') : '' }) : '');
// Weight/cost inputs belong to every pool with several candidates (weights also
// steer the hash); cost only to latency mode (leastping, not overridden by the IP hash).
const weightsFixed = (stickyMode !== 'source' && algorithm === 'static-rr') || (stickyMode === 'source' && draft.stickyHash === 'map-based');
@@ -1194,31 +1196,31 @@ export function RouteEditorPage() {
const listenerLabel = `${isWildcardIP ? '*' : (draft.listenerIP || '—')}:${draft.listenerPort || '—'}`;
const connectionSummary = [
draft.matchMode === 'sni'
- ? (draft.snis.length ? `SNI · ${draft.snis.length} ${plural(draft.snis.length, ['домен', 'домена', 'доменов'])}` : 'SNI')
- : 'Фолбэк',
+ ? (draft.snis.length ? t('editor.sniDomainsCount', { count: draft.snis.length }) : 'SNI')
+ : t('node.fallback'),
listenerLabel,
- draft.acceptProxyEnabled ? 'принимает PROXY' : '',
+ draft.acceptProxyEnabled ? t('editor.acceptsProxy') : '',
].filter(Boolean).join(' · ');
const algorithmLabel = balanceAlgorithmOptions.find((o) => o.value === balanceAlgorithmFamily(algorithm))?.label.toLowerCase();
const distributionLabel = !showSticky ? ''
- : stickyMode === 'source' ? 'закрепление по IP'
- : stickyMode === 'source_table' ? `запоминание · ${algorithmLabel}` : algorithmLabel;
+ : stickyMode === 'source' ? t('editor.pinnedByIp')
+ : stickyMode === 'source_table' ? t('editor.stickinessAlgorithmlabel', { algorithmLabel }) : algorithmLabel;
const targetSummary = [
- `${draft.servers.length} ${plural(draft.servers.length, ['сервер', 'сервера', 'серверов'])}`,
- hasDNSPool ? 'DNS-пул' : '',
- showBalanceMode ? (balanceMode === 'pool' ? 'пул' : 'основной + резервные') : '',
+ t('editor.serversCount', { count: draft.servers.length }),
+ hasDNSPool ? t('editor.dnsPool') : '',
+ showBalanceMode ? (balanceMode === 'pool' ? t('editor.pool') : t('editor.primaryBackups')) : '',
distributionLabel ?? '',
].filter(Boolean).join(' · ');
const advancedParts: string[] = [];
- if (draft.quotaEnabled && draft.quotaValue !== '') advancedParts.push(`лимит ${draft.quotaValue} ${draft.quotaUnit}`);
+ if (draft.quotaEnabled && draft.quotaValue !== '') advancedParts.push(t('editor.limitQuotavalueQuotaunit', { quotaValue: draft.quotaValue, quotaUnit: draft.quotaUnit }));
if (draft.clientBandwidthEnabled) {
const parts = [];
if (draft.clientUploadMbps !== '') parts.push(`↑${draft.clientUploadMbps}`);
if (draft.clientDownloadMbps !== '') parts.push(`↓${draft.clientDownloadMbps}`);
- if (parts.length) advancedParts.push(`скорость ${parts.join(' ')} Мбит/с${draft.shaperMode === 'kernel' ? ' · ядро' : ''}`);
+ if (parts.length) advancedParts.push(t('editor.speedP0MbitSP1', { p0: parts.join(' '), p1: draft.shaperMode === 'kernel' ? t('misc.kernel') : '' }));
}
- const advancedSummary = advancedParts.join(' · ') || 'Лимит трафика и скорости не заданы';
+ const advancedSummary = advancedParts.join(' · ') || t('editor.noTrafficOrSpeedLimits');
const hasAdditionalError = submitAttempted && errors.some((e) =>
['quota', 'clientUploadMbps', 'clientDownloadMbps', 'bandwidth'].includes(e.field as string),
);
@@ -1231,37 +1233,37 @@ export function RouteEditorPage() {
const pendingErrors = submitAttempted ? errors.length : 0;
const missingHint = !submitAttempted && activationErrors.length > 0 ? missingFieldsHint(activationErrors) : '';
const barStatus = saving
- ? { tone: 'busy', text: 'Сохраняем…' }
- : saveError ? { tone: 'error', text: 'Не сохранено' }
+ ? { tone: 'busy', text: t('editor.saving') }
+ : saveError ? { tone: 'error', text: t('editor.notSaved') }
: savedMessage && !dirty ? { tone: 'saved', text: savedMessage }
- : dirty ? { tone: 'dirty', text: 'Есть несохранённые изменения' }
- : { tone: 'clean', text: editing ? 'Изменений нет' : 'Новый маршрут' };
+ : dirty ? { tone: 'dirty', text: t('editor.unsavedChanges') }
+ : { tone: 'clean', text: editing ? t('editor.noChanges') : t('editor.newRoute') };
return (
setTemplateDialog(false)}
- title={templateEdit ? 'Обновить шаблон' : 'Сохранить как шаблон'}
+ title={templateEdit ? t('editor.updateTemplate') : t('node.saveAsTemplate')}
initialName={templateName}
initialDescription={templateDescription}
onSave={async (name, description) => {
if (draftErrors.length) throw new Error(draftErrors.map(e => e.message).join(' '));
const value = await saveTemplate(name, description, routePayload(draft, false), templateEdit ? loadedTemplate : undefined);
if (templateEdit) { setLoadedTemplate(value); setInitialSignature(JSON.stringify(draft)); }
- setTemplateDialog(false); setSavedMessage('Шаблон сохранён');
+ setTemplateDialog(false); setSavedMessage(t('editor.templateSaved'));
}}
/>
Ноды/ {data.node.name}/ {editing ? 'Маршрут' : 'Новый маршрут'} >}
- backAction={ }
- title={templateEdit ? `Шаблон: ${loadedTemplate?.name || ''}` : editing ? (draft.name.trim() || 'Без имени') : 'Новый маршрут'}
+ breadcrumb={<> {t('app.nodes')}/ {data.node.name}/ {editing ? t('nodes.route') : t('editor.newRoute')} >}
+ backAction={ }
+ title={templateEdit ? t('editor.templateP0', { p0: loadedTemplate?.name || '' }) : editing ? (draft.name.trim() || t('nodes.unnamed')) : t('editor.newRoute')}
badge={(
- Состояние:
- {editingEnabled ? 'Включён' : editing ? 'Выключен' : 'Черновик'}
+ {t('editor.status')}
+ {editingEnabled ? t('editor.enabled') : editing ? t('node.disabled') : t('node.draft')}
)}
/>
@@ -1269,42 +1271,42 @@ export function RouteEditorPage() {
{/* 1 — Подключение (имя маршрута — первое поле строки) */}
-
+
{/* Fixed areas: companion inputs (IP, SNI, trusted addresses) always own their slot. */}
-
+
update('name', event.currentTarget.value)} error={showError(['name']) ? true : undefined} required maxLength={80} aria-describedby="route-name-hint" />
-
+
update('matchMode', value as RouteMatchMode)} data={[
- { value: 'sni', label: 'По SNI' },
- { value: 'fallback', label: 'Фолбэк' },
+ { value: 'sni', label: t('editor.bySni') },
+ { value: 'fallback', label: t('node.fallback') },
]} aria-labelledby="route-match-label" aria-describedby="route-match-hint" />
-
+
{
update('listenerIP', value === 'all' ? '*' : (data.node.address || ''));
}} data={[
- { value: 'all', label: 'Все IP' },
- { value: 'specific', label: 'Один IP' },
+ { value: 'all', label: t('editor.allIps') },
+ { value: 'specific', label: t('editor.singleIp') },
]} aria-labelledby="route-listen-label" />
{/* Always rendered: «Все IP» keeps the slot as a disabled field, nothing reflows. */}
update('listenerIP', event.currentTarget.value)}
error={listenerIPMode === 'specific' && showError(['listenerIP', 'listener']) ? true : undefined}
disabled={listenerIPMode !== 'specific'}
- aria-label="IP-адрес listener"
+ aria-label={t('editor.listenerIpAddress')}
required={listenerIPMode === 'specific'}
/>
-
+
update('listenerPort', value === '' ? '' : Number(value))} error={showError(['listenerPort']) ? true : undefined} min={1} max={65535} allowDecimal={false} inputMode="numeric" required aria-describedby="route-port-hint" />
{draft.matchMode === 'sni' ? (
-
+
) : (
-
-
+
+
)}
-
+
-
+
- Клиенты без PROXY protocol не подключатся к этому порту — это действует на все маршруты порта.
+ {t('editor.clientsWithoutProxyProtocolWill')}
)}
@@ -1364,12 +1366,12 @@ export function RouteEditorPage() {
{/* 2 — Назначение */}
-
+
{/* Header row: hint (fixed two-line slot) on the left, the mode switch always in the same place on the right. */}
-
-
+
+
@@ -1407,9 +1409,9 @@ export function RouteEditorPage() {
-
+
-
+
update('proxyProtocol', value as ProxyProtocol)}
data={[
- { value: 'none', label: 'Нет' }, { value: 'v1', label: 'v1' }, { value: 'v2', label: 'v2' },
+ { value: 'none', label: t('editor.no') }, { value: 'v1', label: 'v1' }, { value: 'v2', label: 'v2' },
]}
aria-labelledby="route-proxy-out-label"
aria-describedby="route-proxy-out-hint"
@@ -1443,23 +1445,23 @@ export function RouteEditorPage() {
update('slowstart', v === '' ? '' : `${v}s`)}
- placeholder="выкл"
+ placeholder={t('editor.off')}
min={1} max={600} allowDecimal={false} inputMode="numeric"
disabled={!healthOn || !showBalanceMode}
error={showBalanceMode && showError(['slowstart']) ? true : undefined}
@@ -1480,7 +1482,7 @@ export function RouteEditorPage() {
aria-controls="route-additional-body"
>
3
- Дополнительно
+ {t('editor.advanced')}
{advancedSummary}
@@ -1488,55 +1490,55 @@ export function RouteEditorPage() {
update('quotaEnabled', event.currentTarget.checked)} aria-label="Лимит трафика" />}
+ control={ update('quotaEnabled', event.currentTarget.checked)} aria-label={t('editor.trafficLimit2')} />}
>
-
+
update('quotaValue', value === '' ? '' : Number(value))} error={showError(['quota']) ? true : undefined} min={0.001} decimalScale={3} inputMode="decimal" required={draft.quotaEnabled} />
- update('quotaUnit', (value ?? 'GiB') as 'GiB' | 'TiB')} allowDeselect={false} data={['GiB', 'TiB']} aria-label="Единица лимита" />
+ update('quotaUnit', (value ?? 'GiB') as 'GiB' | 'TiB')} allowDeselect={false} data={['GiB', 'TiB']} aria-label={t('editor.limitUnit')} />
-
+
update('quotaPeriod', (value ?? 'calendar_month') as QuotaPeriod)} allowDeselect={false} data={quotaPeriodOptions.map(({ value, label }) => ({ value, label }))} />
- update('quotaAction', (value ?? 'observe') as QuotaAction)} allowDeselect={false} data={[{ value: 'observe', label: 'Только уведомить' }, { value: 'block_new', label: 'Блокировать новые' }]} />
+ update('quotaAction', (value ?? 'observe') as QuotaAction)} allowDeselect={false} data={[{ value: 'observe', label: t('editor.notifyOnly') }, { value: 'block_new', label: t('editor.blockNewConnections') }]} />
update('clientBandwidthEnabled', event.currentTarget.checked)} aria-label="Ограничение скорости" />}
+ control={ update('clientBandwidthEnabled', event.currentTarget.checked)} aria-label={t('editor.speedLimit')} />}
>
-
- update('clientUploadMbps', value)} error={showError(['clientUploadMbps', 'bandwidth']) ? true : undefined} min={1} max={1_000_000} allowDecimal={false} inputMode="numeric" />
+
+ update('clientUploadMbps', value)} error={showError(['clientUploadMbps', 'bandwidth']) ? true : undefined} min={1} max={1_000_000} allowDecimal={false} inputMode="numeric" />
-
- update('clientDownloadMbps', value)} error={showError(['clientDownloadMbps', 'bandwidth']) ? true : undefined} min={1} max={1_000_000} allowDecimal={false} inputMode="numeric" />
+
+ update('clientDownloadMbps', value)} error={showError(['clientDownloadMbps', 'bandwidth']) ? true : undefined} min={1} max={1_000_000} allowDecimal={false} inputMode="numeric" />
- {shaperHint && !shaperIsError && Ядро: нужен Node Agent {KERNEL_SHAPER_MIN_AGENT}, на ноде {data.agentVersion ?? 'неизвестно'} }
+ {shaperHint && !shaperIsError && {t('editor.kernelNodeAgentRequired', { version: KERNEL_SHAPER_MIN_AGENT, current: data.agentVersion ?? t('editor.unknown') })} }
@@ -1563,7 +1565,7 @@ export function RouteEditorPage() {
{(submitAttempted && errors.length > 0) && (
-
} title={`Нужно исправить: ${errors.length}`} role="alert">
+
} title={t('editor.needsFixingLength', { length: errors.length })} role="alert">
{errors.slice(0, 5).map((error, index) => {error.message} )}
)}
@@ -1578,44 +1580,44 @@ export function RouteEditorPage() {
{pendingErrors > 0 && (
- Нужно исправить: {pendingErrors}
+ {t('editor.needsFixing')} {pendingErrors}
)}
- {templateEdit ? 'Сохранить шаблон' : 'Сохранить как шаблон'}
- {!templateEdit && !editingEnabled && void save('draft')} loading={saving === 'draft'} disabled={Boolean(saving && saving !== 'draft')}>Сохранить черновик }
- {!templateEdit && {editingEnabled ? 'Сохранить и применить' : 'Сохранить и включить'} }
+ {templateEdit ? t('editor.saveTemplate') : t('node.saveAsTemplate')}
+ {!templateEdit && !editingEnabled && void save('draft')} loading={saving === 'draft'} disabled={Boolean(saving && saving !== 'draft')}>{t('editor.saveDraft')} }
+ {!templateEdit && {editingEnabled ? t('editor.saveAndApply') : t('editor.saveAndEnable')} }
-
+
{/* Предпросмотр HAProxy — закреплённая колонка справа */}
-
Предпросмотр HAProxy
+ {t('editor.haproxyPreview')}
- {valid ? : submitAttempted ? : }{valid ? 'Форма заполнена верно' : submitAttempted ? `Ошибок: ${errors.length}` : 'Заполните обязательные поля'}
- {preview.merged ? `Общий frontend с ${preview.merged} ${plural(preview.merged, ['маршрутом', 'маршрутами', 'маршрутами'])}` : 'Отдельный frontend'}{draft.expertOverride.trim() ? ' · есть ручные директивы' : ''}
+ {valid ? : submitAttempted ? : }{valid ? t('editor.formIsValid') : submitAttempted ? t('editor.errorsLength', { length: errors.length }) : t('editor.fillInTheRequiredFields')}
+ {preview.merged ? t('editor.sharedFrontendWith', { count: preview.merged }) : t('editor.dedicatedFrontend')}{draft.expertOverride.trim() ? ` ${t('editor.hasManualDirectives')}` : ''}
}>
- Перед применением Agent проверит конфиг через
haproxy -c; UFW меняется только по политике ноды. Активные соединения не разрываются.
+ {t('editor.beforeApplyingTheAgentValidates')}
haproxy -c{t('editor.ufwChangesOnlyAccordingTo')}
-
-
haproxy.cfg только чтение
+
+
haproxy.cfg {t('editor.readOnly')}
{preview.config}
- Ручные директивы backend Секции global, frontend, listen, backend и resolvers запрещены.
- { setExpertEnabled(event.currentTarget.checked); if (!event.currentTarget.checked) update('expertOverride', ''); }} aria-label="Разрешить ручные директивы" />
+ {t('editor.manualBackendDirectives')} {t('editor.globalFrontendListenBackendAnd')}
+ { setExpertEnabled(event.currentTarget.checked); if (!event.currentTarget.checked) update('expertOverride', ''); }} aria-label={t('editor.allowManualDirectives')} />
- {expertEnabled && update('expertOverride', event.currentTarget.value)} error={showError(['expert'])} placeholder={'timeout connect 5s\nmaxconn 2000'} autosize minRows={8} maxRows={20} classNames={{ input: 'nf-code-input' }} />}
+ {expertEnabled && update('expertOverride', event.currentTarget.value)} error={showError(['expert'])} placeholder={'timeout connect 5s\nmaxconn 2000'} autosize minRows={8} maxRows={20} classNames={{ input: 'nf-code-input' }} />}
@@ -1623,8 +1625,8 @@ export function RouteEditorPage() {
-
blocker.state === 'blocked' && blocker.reset()} title="Выйти без сохранения?" size="sm" closeOnClickOutside closeOnEscape classNames={{ content: 'nf-dialog', header: 'nf-dialog__header', body: 'nf-dialog__body' }}>
- Изменения маршрута будут потеряны. HAProxy и UFW не изменятся.
blocker.state === 'blocked' && blocker.reset()}>Продолжить Выйти
+ blocker.state === 'blocked' && blocker.reset()} title={t('editor.leaveWithoutSaving')} size="sm" closeOnClickOutside closeOnEscape classNames={{ content: 'nf-dialog', header: 'nf-dialog__header', body: 'nf-dialog__body' }}>
+ {t('editor.routeChangesWillBeLost')}
blocker.state === 'blocked' && blocker.reset()}>{t('editor.continue')} {t('app.signOut')}
);
diff --git a/frontend/src/pages/TrafficPage.tsx b/frontend/src/pages/TrafficPage.tsx
index 3032f1d..3d0ad59 100644
--- a/frontend/src/pages/TrafficPage.tsx
+++ b/frontend/src/pages/TrafficPage.tsx
@@ -23,6 +23,7 @@ import { isUnauthorized } from '../lib/api';
import type { DashboardNode } from '../lib/contracts';
import { formatBitrate, formatBytes, formatNumber } from '../lib/format';
import '../styles/traffic.css';
+import { locale } from '../i18n';
type StatusFilter = 'all' | TrafficNodeState;
@@ -157,7 +158,7 @@ export function TrafficPage() {
));
const backgroundError = Boolean(overview && result.isError);
const lastSuccessLabel = result.dataUpdatedAt
- ? new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(result.dataUpdatedAt)
+ ? new Intl.DateTimeFormat(locale(), { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(result.dataUpdatedAt)
: '—';
const chartStatus: { tone: 'online' | 'degraded' | 'offline' | 'stale'; label: string } = (() => {
if (backgroundError || historyStale) return { tone: 'stale', label: 'Данные устарели' };
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index b18578b..6942088 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -162,6 +162,8 @@ button, a { touch-action: manipulation; }
.nf-nav-item.is-active::before { content: ""; position: absolute; left: 0; top: 11px; bottom: 11px; width: 2px; border-radius: 2px; background: var(--nf-accent); }
.nf-sidebar__account { flex: 0 0 auto; border-top: 1px solid var(--nf-border-soft); margin-top: auto; padding: 16px 4px 0; display: grid; grid-template-columns: 32px minmax(0, 1fr) 34px; align-items: center; gap: 8px; }
.nf-sidebar__account > div { min-width: 0; }
+.nf-sidebar__lang { flex: 0 0 auto; margin-bottom: 8px; width: 100%; border: 0; background: transparent; font: inherit; cursor: pointer; text-align: left; }
+.nf-sidebar__lang small { margin-left: auto; color: var(--nf-text-secondary); font-size: 11px; letter-spacing: .04em; }
.nf-sidebar__account p { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nf-mobile-header { background: var(--nf-overlay); border-bottom: 1px solid var(--nf-border); padding: 0 16px; display: flex; align-items: center; justify-content: space-between; transform: translateZ(0); backface-visibility: hidden; }
@@ -414,6 +416,7 @@ button, a { touch-action: manipulation; }
@keyframes nf-auth-check { to { transform: translate(-50%, -50%) scaleX(1); } }
.nf-login { display: grid; place-content: center; padding: 24px; }
.nf-login__panel { width: min(420px, calc(100vw - 32px)); display: grid; gap: 22px; padding: 28px; background: var(--nf-surface); border: 1px solid var(--nf-border); border-radius: var(--nf-radius-module); }
+.nf-login__top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.nf-login__panel h1 { margin: 0; font-size: 21px; }
.nf-login__panel p, .nf-dialog__intro { color: var(--nf-text-secondary); line-height: 1.55; margin: 5px 0 0; }
.nf-dialog {
diff --git a/frontend/tests/i18n.test.ts b/frontend/tests/i18n.test.ts
new file mode 100644
index 0000000..a9f0554
--- /dev/null
+++ b/frontend/tests/i18n.test.ts
@@ -0,0 +1,50 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { ru } from '../src/locales/ru.ts';
+import { en } from '../src/locales/en.ts';
+import { detectLang, translate } from '../src/i18n.ts';
+
+const placeholders = (value: unknown): string[] =>
+ [...new Set(JSON.stringify(value).match(/\{\w+\}/g) ?? [])].sort();
+
+test('en and ru dictionaries have identical keys', () => {
+ assert.deepEqual(Object.keys(en).sort(), Object.keys(ru).sort());
+});
+
+test('every English message is non-empty and has no Cyrillic', () => {
+ for (const [key, value] of Object.entries(en)) {
+ const text = JSON.stringify(value);
+ assert.ok(text.replace(/"/g, '').trim().length > 0, key);
+ if (key !== 'app.languageRu') assert.doesNotMatch(text, /[А-Яа-яЁё]/, key);
+ }
+});
+
+test('en and ru messages use the same placeholders', () => {
+ for (const key of Object.keys(ru) as (keyof typeof ru)[]) {
+ assert.deepEqual(placeholders(en[key]), placeholders(ru[key]), key);
+ }
+});
+
+test('plural forms and interpolation', () => {
+ assert.equal(translate('ru', 'editor.serversCount', { count: 1 }), '1 сервер');
+ assert.equal(translate('ru', 'editor.serversCount', { count: 3 }), '3 сервера');
+ assert.equal(translate('ru', 'editor.serversCount', { count: 5 }), '5 серверов');
+ assert.equal(translate('en', 'editor.serversCount', { count: 1 }), '1 server');
+ assert.equal(translate('en', 'editor.serversCount', { count: 2 }), '2 servers');
+ assert.equal(translate('en', 'nodes.openNodeName', { name: 'edge-1' }), 'Open node edge-1');
+});
+
+test('language detection: stored choice, then browser, then ru', () => {
+ assert.equal(detectLang('en', ['ru-RU']), 'en');
+ assert.equal(detectLang(null, ['de-DE', 'en-GB']), 'en');
+ assert.equal(detectLang(null, ['ru']), 'ru');
+ assert.equal(detectLang('xx', ['de']), 'ru');
+});
+
+test('API error codes: known codes localized, unknown keep the server message', async () => {
+ const { apiErrorText } = await import('../src/i18n.ts');
+ assert.equal(apiErrorText('node_not_found', 'x'), 'Нода не найдена');
+ assert.equal(apiErrorText('some_new_code', 'server text'), 'server text');
+ assert.equal(apiErrorText(undefined, 'server text'), 'server text');
+ assert.equal(translate('en', 'apiError.route_copy_failed'), 'Failed to create a draft on the node');
+});
diff --git a/frontend/tests/setup.ts b/frontend/tests/setup.ts
new file mode 100644
index 0000000..dc23036
--- /dev/null
+++ b/frontend/tests/setup.ts
@@ -0,0 +1,15 @@
+// Loaded via `node --import` before every test file: src/ uses extensionless
+// relative imports (Vite/Bundler resolution); map them to the .ts files so
+// node --experimental-strip-types can load modules that import src/i18n.
+import { registerHooks } from 'node:module';
+
+registerHooks({
+ resolve(specifier, context, nextResolve) {
+ try {
+ return nextResolve(specifier, context);
+ } catch (error) {
+ if (specifier.startsWith('.') && !/\.[cm]?[jt]sx?$/.test(specifier)) return nextResolve(`${specifier}.ts`, context);
+ throw error;
+ }
+ },
+});
From 441b5b5e73ed634f2c7ec47bdd4a0c6282b899a3 Mon Sep 17 00:00:00 2001
From: SHOKOV <74192268+SHOKOVprod@users.noreply.github.com>
Date: Thu, 15 Oct 2026 04:20:37 +0300
Subject: [PATCH 4/6] panel: stable keys for HAProxy lint messages, build note
and copy suffix
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
LintIssue gains message_key and params (Russian message kept); the
'Собрано' revision note gets metadata note_key; route copy accepts an
optional copy_suffix so the UI can localize ' (копия)'.
---
internal/panel/haproxy_lint.go | 26 +++++-
internal/panel/haproxy_lint_messages.go | 92 +++++++++++++++++++
internal/panel/haproxy_lint_messages_test.go | 58 ++++++++++++
internal/panel/haproxy_lint_test.go | 3 +-
internal/panel/haproxy_renderer_http_test.go | 1 +
internal/panel/http.go | 6 +-
internal/panel/route_templates.go | 10 +-
internal/panel/route_templates_review_test.go | 7 ++
8 files changed, 194 insertions(+), 9 deletions(-)
create mode 100644 internal/panel/haproxy_lint_messages.go
create mode 100644 internal/panel/haproxy_lint_messages_test.go
diff --git a/internal/panel/haproxy_lint.go b/internal/panel/haproxy_lint.go
index 05984d0..130a196 100644
--- a/internal/panel/haproxy_lint.go
+++ b/internal/panel/haproxy_lint.go
@@ -33,6 +33,9 @@ type LintIssue struct {
Severity string `json:"severity"`
Code string `json:"code"`
Message string `json:"message"`
+ // MessageKey and Params let the UI localize Message (lint., {p0}…).
+ MessageKey string `json:"message_key,omitempty"`
+ Params []string `json:"params,omitempty"`
}
type LintSection struct {
@@ -127,7 +130,7 @@ type linter struct {
namedDefaults bool
}
-func (l *linter) add(line int, w lintWord, severity, code, message string) {
+func (l *linter) add(line int, w lintWord, severity, code, format string, args ...any) {
if len(l.issues) >= maxLintIssues {
l.truncated = true
return
@@ -139,15 +142,15 @@ func (l *linter) add(line int, w lintWord, severity, code, message string) {
if end < col {
end = col
}
- l.issues = append(l.issues, LintIssue{Line: line, Column: col, EndColumn: end, Severity: severity, Code: code, Message: message})
+ l.issues = append(l.issues, newLintIssue(line, col, end, severity, code, format, args...))
}
func (l *linter) errorf(line int, w lintWord, code, format string, args ...any) {
- l.add(line, w, LintSeverityError, code, fmt.Sprintf(format, args...))
+ l.add(line, w, LintSeverityError, code, format, args...)
}
func (l *linter) warnf(line int, w lintWord, code, format string, args ...any) {
- l.add(line, w, LintSeverityWarning, code, fmt.Sprintf(format, args...))
+ l.add(line, w, LintSeverityWarning, code, format, args...)
}
// LintHAProxyConfig statically checks a complete HAProxy configuration.
@@ -331,7 +334,7 @@ func (l *linter) finish() LintResult {
return issues[i].Column < issues[j].Column
})
if l.truncated {
- issues = append(issues, LintIssue{Line: 1, Column: 1, EndColumn: 1, Severity: LintSeverityWarning, Code: "too_many_issues", Message: fmt.Sprintf("Показаны первые %d замечаний", maxLintIssues)})
+ issues = append(issues, newLintIssue(1, 1, 1, LintSeverityWarning, "too_many_issues", "Показаны первые %d замечаний", maxLintIssues))
}
ports := make([]int, 0, len(l.ports))
for port := range l.ports {
@@ -850,3 +853,16 @@ func lintTokenize(line string) ([]lintWord, int) {
}
return words, 0
}
+
+// newLintIssue formats the Russian message and attaches its stable key and
+// string params so the UI can render the same issue in another language.
+func newLintIssue(line, col, end int, severity, code, format string, args ...any) LintIssue {
+ issue := LintIssue{Line: line, Column: col, EndColumn: end, Severity: severity, Code: code, Message: fmt.Sprintf(format, args...)}
+ if key, ok := lintMessageKeys[format]; ok {
+ issue.MessageKey = key
+ for _, arg := range args {
+ issue.Params = append(issue.Params, fmt.Sprint(arg))
+ }
+ }
+ return issue
+}
diff --git a/internal/panel/haproxy_lint_messages.go b/internal/panel/haproxy_lint_messages.go
new file mode 100644
index 0000000..86a02ba
--- /dev/null
+++ b/internal/panel/haproxy_lint_messages.go
@@ -0,0 +1,92 @@
+// Code generated by hand from the lint message formats; keep in sync with frontend/src/locales (lint.*).
+
+package panel
+
+// lintMessageKeys maps a Russian lint message format to a stable message key.
+// The frontend translates `lint.` with params p0…pN; the Russian text stays
+// in LintIssue.Message for API compatibility.
+var lintMessageKeys = map[string]string{
+ "Нет backend «%s» (маршрут %s): трафик и квоты этого маршрута не будут учитываться": "runtime_name_missing",
+ "В backend «%s» нет server «%s»: квоты маршрута не будут применяться": "runtime_name_missing_2",
+ "Конфигурация больше %d КиБ": "config_too_large",
+ "Конфигурация пуста": "config_empty",
+ "Недопустимый NUL-байт": "nul_byte",
+ "Строка не в кодировке UTF-8": "invalid_utf8",
+ "Незакрытая кавычка": "unterminated_quote",
+ "Больше %d слов в строке: HAProxy отбросит остаток": "too_many_words",
+ "У .if нет условия": "cond_missing_expression",
+ ".elif без .if": "cond_unbalanced",
+ ".elif после .else": "cond_unbalanced_2",
+ ".else без .if": "cond_unbalanced_3",
+ "Повторный .else": "cond_unbalanced_4",
+ ".endif без .if": "cond_unbalanced_5",
+ "Неизвестная директива «%s»": "unknown_directive",
+ "Неизвестная секция «%s»": "unknown_section",
+ "Директива «%s» вне секции": "outside_section",
+ "Лишний символ «%s» после «%s»": "stray_punctuation",
+ "Префикс «%s» не поддерживается для «%s»": "negation_not_supported",
+ ".if без .endif": "cond_unbalanced_6",
+ "Backend «%s» не определён": "undefined_backend",
+ "У секции %s нет имени": "missing_name",
+ "Недопустимые символы в имени «%s»": "invalid_name",
+ "Прокси «%s» уже объявлен в строке %d": "duplicate_proxy",
+ "Секция %s «%s» уже объявлена в строке %d": "duplicate_section",
+ "У bind нет адреса": "bind_missing_address",
+ "У %s нет имени или адреса": "missing_address",
+ "У %s не указан backend": "missing_backend",
+ "У timeout не указан тип": "timeout_missing_kind",
+ "Неизвестный тип timeout «%s»": "unknown_timeout",
+ "timeout %s игнорируется в секции %s": "timeout_ignored",
+ "У timeout %s нет значения": "timeout_missing_value",
+ "Неверное значение timeout «%s» (пример: 5s, 500ms, 15m)": "invalid_timeout",
+ "У server нет имени": "server_missing_name",
+ "У server «%s» нет адреса": "server_missing_address",
+ "server-template: нужны префикс, количество и адрес": "server_template_incomplete",
+ "Неверный диапазон «%s»": "invalid_server_template_count",
+ "Неверное количество «%s»": "invalid_server_template_count_2",
+ "Server «%s» уже объявлен в %s «%s»": "duplicate_server",
+ "Пустой адрес «%s»": "invalid_address",
+ "%s: «%s»": "invalid_address_2",
+ "Нет хоста в адресе «%s»": "invalid_address_3",
+ "Нет порта в адресе «%s»": "invalid_port",
+ "Нет порта в адресе «%s»: будет использован порт клиента": "server_port_missing",
+ "Неверный порт «%s»": "invalid_port_2",
+ "Неверный порт «%s» (1–65535)": "invalid_port_3",
+ "Пустой адрес в bind": "invalid_address_4",
+ "Нет порта в bind «%s»": "invalid_port_4",
+ "Больше %d портов в bind: firewall не сможет их открыть": "too_many_ports",
+ "Неизвестная директива «%s» в секции %s": "unknown_directive_2",
+ "Неизвестный параметр «%s» у %s": "unknown_keyword",
+ "«%s» недопустим в безымянной секции defaults (задайте ей имя)": "keyword_not_allowed",
+ "«%s» недопустим в секции %s": "keyword_not_allowed_2",
+ "У option не указано имя": "option_missing_name",
+ "Неизвестная опция «%s» — лишний символ «%s»": "unknown_option",
+ "Неизвестная опция «%s»": "unknown_option_2",
+ "Префикс «%s» не поддерживается для option %s": "negation_not_supported_2",
+ "option %s игнорируется в секции %s": "option_ignored",
+ "option %s не принимает аргументов": "too_many_args",
+ "option %s принимает только «clf»": "invalid_option_arg",
+ "Лишний аргумент option %s": "too_many_args_2",
+ "У mode не указан режим (tcp или http)": "invalid_mode",
+ "mode health больше не поддерживается": "invalid_mode_2",
+ "Неизвестный режим «%s» (tcp, http, log, spop)": "invalid_mode_3",
+ "mode %s недопустим в секции %s": "invalid_mode_4",
+ "Лишний аргумент mode": "too_many_args_3",
+ "У balance не указан алгоритм": "invalid_balance",
+ "Неизвестный алгоритм balance «%s»": "invalid_balance_2",
+ "hash-type: укажите consistent или map-based": "invalid_hash_type",
+ "Лишний аргумент hash-type": "too_many_args_4",
+ "Неверный аргумент hash-type «%s»": "invalid_hash_type_2",
+ "У log не указан адрес": "log_missing_target",
+ "Лишний аргумент log global": "too_many_args_5",
+ "У log не указан facility (например local0)": "log_missing_facility",
+ "Лишний аргумент log «%s»": "too_many_args_6",
+ "Неизвестный %s log «%s»": "invalid_log",
+ "Неизвестный параметр %s «%s»": "unknown_keyword_2",
+ "У параметра «%s» нет значения": "missing_value",
+ "«%s» — неверное время для %s (пример: 5s, 500ms)": "invalid_duration",
+ "%s ожидает целое число ≥ 1": "invalid_number",
+ "weight — целое число 0–256": "invalid_weight",
+ "Лишний символ «%s» в конце строки": "stray_punctuation_2",
+ "Показаны первые %d замечаний": "too_many_issues",
+}
diff --git a/internal/panel/haproxy_lint_messages_test.go b/internal/panel/haproxy_lint_messages_test.go
new file mode 100644
index 0000000..61d29bb
--- /dev/null
+++ b/internal/panel/haproxy_lint_messages_test.go
@@ -0,0 +1,58 @@
+package panel
+
+import (
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "strconv"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// Every lint message format must have a stable key, so the UI can translate it.
+func TestLintMessageKeysCoverAllFormats(t *testing.T) {
+ fset := token.NewFileSet()
+ seen := 0
+ for _, file := range []string{"haproxy_lint.go", "haproxy_lint_checks.go"} {
+ f, err := parser.ParseFile(fset, file, nil, 0)
+ require.NoError(t, err)
+ ast.Inspect(f, func(n ast.Node) bool {
+ call, ok := n.(*ast.CallExpr)
+ if !ok {
+ return true
+ }
+ index := -1
+ switch fn := call.Fun.(type) {
+ case *ast.SelectorExpr:
+ if fn.Sel.Name == "errorf" || fn.Sel.Name == "warnf" {
+ index = 3
+ }
+ case *ast.Ident:
+ if fn.Name == "newLintIssue" {
+ index = 5
+ }
+ }
+ if index < 0 || len(call.Args) <= index {
+ return true
+ }
+ lit, ok := call.Args[index].(*ast.BasicLit)
+ if !ok {
+ return true
+ }
+ format, err := strconv.Unquote(lit.Value)
+ require.NoError(t, err)
+ _, ok = lintMessageKeys[format]
+ require.True(t, ok, "no lint message key for %q (%s)", format, fset.Position(lit.Pos()))
+ seen++
+ return true
+ })
+ }
+ require.Greater(t, seen, 80)
+
+ result := LintHAProxyConfig("frontend fe\n bind :99999\n")
+ require.NotEmpty(t, result.Issues)
+ for _, issue := range result.Issues {
+ require.NotEmpty(t, issue.MessageKey, issue.Message)
+ }
+}
diff --git a/internal/panel/haproxy_lint_test.go b/internal/panel/haproxy_lint_test.go
index c590858..1bf6856 100644
--- a/internal/panel/haproxy_lint_test.go
+++ b/internal/panel/haproxy_lint_test.go
@@ -191,7 +191,8 @@ func TestLintDetectsErrors(t *testing.T) {
}
assert.ElementsMatch(t, ports, result.ListenerTCPPorts)
issue := result.Issues[0]
- assert.Equal(t, LintIssue{Line: 1, Column: 1, EndColumn: 8, Severity: "error", Code: "outside_section", Message: issue.Message}, issue)
+ assert.Equal(t, LintIssue{Line: 1, Column: 1, EndColumn: 8, Severity: "error", Code: "outside_section", Message: issue.Message, MessageKey: "outside_section", Params: issue.Params}, issue)
+ assert.Len(t, issue.Params, 1)
}
func TestLintRejectsBinaryAndOversized(t *testing.T) {
diff --git a/internal/panel/haproxy_renderer_http_test.go b/internal/panel/haproxy_renderer_http_test.go
index df441b6..275f2ff 100644
--- a/internal/panel/haproxy_renderer_http_test.go
+++ b/internal/panel/haproxy_renderer_http_test.go
@@ -48,6 +48,7 @@ func TestCreateConfigRevisionFromRoutesDoesNotAssignDesired(t *testing.T) {
w = request(t, handler(f), http.MethodPost, "/api/v1/nodes/"+testNodeID+"/config-revisions/from-routes", `{}`, testAdminToken)
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
assert.True(t, strings.HasPrefix(f.createdConfig.Note, "Собрано: маршрутов 1"))
+ assert.Equal(t, "built_from_routes", f.createdConfig.Metadata["note_key"])
}
func TestCreateConfigRevisionFromRoutesValidatesInputAndRouteSet(t *testing.T) {
diff --git a/internal/panel/http.go b/internal/panel/http.go
index 190a6c2..01dcf15 100644
--- a/internal/panel/http.go
+++ b/internal/panel/http.go
@@ -1372,10 +1372,14 @@ func (a *API) configRevisionFromRoutes(w http.ResponseWriter, r *http.Request, n
writeError(w, status, code, message)
return
}
+ metadata := renderMetadata(result)
if in.Note == "" {
in.Note = "Собрано: " + routeRenderSummary(result)
+ // Stable key for the generated note; the UI renders it from
+ // metadata.enabled_routes / metadata.listeners in its own language.
+ metadata["note_key"] = "built_from_routes"
}
- revision, err := a.store.CreateConfigRevision(r.Context(), nodeID, result.Config, in.Note, renderMetadata(result))
+ revision, err := a.store.CreateConfigRevision(r.Context(), nodeID, result.Config, in.Note, metadata)
respondStore(w, revision, err, http.StatusCreated)
}
diff --git a/internal/panel/route_templates.go b/internal/panel/route_templates.go
index 815f01d..02ff293 100644
--- a/internal/panel/route_templates.go
+++ b/internal/panel/route_templates.go
@@ -310,6 +310,8 @@ type routeCopyInput struct {
NodeIDs []string `json:"node_ids"`
Name string `json:"name"`
ExpectedVersion int64 `json:"expected_version"`
+ // CopySuffix optionally localizes the same-node copy suffix (default " (копия)").
+ CopySuffix string `json:"copy_suffix"`
}
type routeCopyResult struct {
NodeID string `json:"node_id"`
@@ -406,11 +408,15 @@ func (a *API) createRouteCopies(w http.ResponseWriter, r *http.Request, config r
}
sameNodeSpec := spec
if in.Name == "" && sourceNodeID != "" {
+ suffix := routeCopySuffix
+ if custom := strings.TrimRight(in.CopySuffix, " "); strings.TrimSpace(custom) != "" && utf8.RuneCountInString(custom) <= 20 && !containsForbiddenControl(custom) {
+ suffix = custom
+ }
base := []rune(spec.Name)
- if limit := 80 - utf8.RuneCountInString(routeCopySuffix); len(base) > limit {
+ if limit := 80 - utf8.RuneCountInString(suffix); len(base) > limit {
base = base[:limit]
}
- sameNodeSpec.Name = strings.TrimRight(string(base), " ") + routeCopySuffix
+ sameNodeSpec.Name = strings.TrimRight(string(base), " ") + suffix
}
results := make([]routeCopyResult, 0, len(in.NodeIDs))
for _, id := range in.NodeIDs {
diff --git a/internal/panel/route_templates_review_test.go b/internal/panel/route_templates_review_test.go
index 443dafb..7d77704 100644
--- a/internal/panel/route_templates_review_test.go
+++ b/internal/panel/route_templates_review_test.go
@@ -47,6 +47,13 @@ func TestRouteCopyReviewFixes(t *testing.T) {
require.Equal(t, testRouteID, event.ResourceID)
require.Equal(t, []string{testNodeID, other, manual}, event.Details["node_ids"])
+ // The UI may localize the same-node suffix.
+ w = request(t, h, "POST", copyPath, fmt.Sprintf(`{"node_ids":[%q],"copy_suffix":" (copy)"}`, testNodeID), testAdminToken)
+ require.Equal(t, 200, w.Code, w.Body.String())
+ results = nil
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &results))
+ require.Equal(t, "source (copy)", results[0].Route.Name)
+
// Template lifecycle audit and schema_version.
w = request(t, h, "POST", "/api/v1/route-templates", fmt.Sprintf(`{"name":"T","source_node_id":%q,"source_route_id":%q}`, testNodeID, testRouteID), testAdminToken)
require.Equal(t, 201, w.Code, w.Body.String())
From 658d91d631e4d10dacba9c047cf084ad60de6315 Mon Sep 17 00:00:00 2001
From: SHOKOV <74192268+SHOKOVprod@users.noreply.github.com>
Date: Thu, 15 Oct 2026 04:20:37 +0300
Subject: [PATCH 5/6] frontend: complete English UI localization
Add node wizard, settings, HAProxy config page and linters, traffic,
HAProxy directive docs (haproxyDocs.en.ts), 65 API error codes, demo
fixtures, mobile route table labels. 1428 keys in each dictionary.
---
frontend/src/components/NodeFlowLogo.tsx | 5 +-
.../features/haproxy-editor/haproxyDocs.en.ts | 508 ++++++++++++++
.../features/haproxy-editor/haproxyDocs.ts | 28 +-
.../haproxy-editor/haproxyLintRules.ts | 69 +-
.../features/haproxy-editor/haproxyModel.ts | 83 +--
.../features/node-detail/NodeRoutesTable.tsx | 16 +-
frontend/src/features/nodes/AddNodeDialog.tsx | 146 ++--
frontend/src/features/releases/platform.ts | 3 +-
frontend/src/features/routes/templates.ts | 2 +-
.../features/traffic/TrafficNodesTable.tsx | 45 +-
.../features/traffic/TrafficRouteRanking.tsx | 23 +-
frontend/src/fixtures/demo-overview.ts | 5 +-
frontend/src/fixtures/demo.ts | 3 +-
frontend/src/locales/en.ts | 654 +++++++++++++++++-
frontend/src/locales/ru.ts | 654 +++++++++++++++++-
frontend/src/pages/HAProxyConfigPage.tsx | 151 ++--
frontend/src/pages/PlaceholderPage.tsx | 5 +-
frontend/src/pages/SettingsPage.tsx | 253 +++----
frontend/src/pages/TrafficPage.tsx | 84 +--
frontend/src/styles/global.css | 9 +-
frontend/src/theme/theme.ts | 3 +-
frontend/tests/i18n.test.ts | 21 +-
22 files changed, 2325 insertions(+), 445 deletions(-)
create mode 100644 frontend/src/features/haproxy-editor/haproxyDocs.en.ts
diff --git a/frontend/src/components/NodeFlowLogo.tsx b/frontend/src/components/NodeFlowLogo.tsx
index f5a3d71..723079f 100644
--- a/frontend/src/components/NodeFlowLogo.tsx
+++ b/frontend/src/components/NodeFlowLogo.tsx
@@ -1,6 +1,7 @@
import { Link, useLocation } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { demoSuffix } from '../lib/navigation';
+import { t } from '../i18n';
let cachedPanelVersion = '';
let panelVersionRequest: Promise | null = null;
@@ -49,7 +50,7 @@ export function NodeFlowLogo() {
}, []);
return (
-
+
@@ -57,7 +58,7 @@ export function NodeFlowLogo() {
NodeFlow
{panelVersion && (
-
+
v{panelVersion}
)}
diff --git a/frontend/src/features/haproxy-editor/haproxyDocs.en.ts b/frontend/src/features/haproxy-editor/haproxyDocs.en.ts
new file mode 100644
index 0000000..5f60e88
--- /dev/null
+++ b/frontend/src/features/haproxy-editor/haproxyDocs.en.ts
@@ -0,0 +1,508 @@
+// English hover documentation for haproxy.cfg: Russian source text (haproxyDocs.ts) → English.
+// Directive names and syntax are kept verbatim; only prose and are translated.
+// Pure data: no runtime imports.
+
+export const DOCS_EN: Record = {
+ "Имя сгенерировано NodeFlow; на него завязаны метрики и квоты. **Не переименовывать.**": "Name generated by NodeFlow; metrics and quotas depend on it. **Do not rename.**",
+ "Параметры процесса HAProxy: лимиты, потоки, логи, stats-сокет, пользователь. Секция одна на весь конфиг.": "HAProxy process settings: limits, threads, logs, stats socket, user. One section per config.",
+ "Значения по умолчанию для всех следующих frontend/backend/listen: режим, таймауты, опции логирования.": "Defaults for all following frontend/backend/listen sections: mode, timeouts, logging options.",
+ "⚠ Если добавить **второй defaults**, то он применяется только к секциям ниже него.": "⚠ If you add a **second defaults**, it applies only to the sections below it.",
+ "Точка входа: слушает порты (bind), принимает соединения и выбирает backend по правилам.": "Entry point: listens on ports (bind), accepts connections and picks a backend by rules.",
+ "Группа серверов назначения, алгоритм балансировки и проверки здоровья.": "Group of destination servers, load-balancing algorithm and health checks.",
+ "Frontend и backend в одной секции: bind и server рядом. Удобно для простых TCP-прокси.": "Frontend and backend in one section: bind and server side by side. Handy for simple TCP proxies.",
+ "Набор DNS-серверов, через которые HAProxy перерезолвит имена серверов во время работы.": "Set of DNS servers HAProxy uses to re-resolve server names at runtime.",
+ "Синхронизация stick-table между процессами HAProxy (в NodeFlow — между старым и новым процессом при reload).": "Stick-table synchronization between HAProxy processes (in NodeFlow — between the old and new process on reload).",
+ "Список пользователей и групп для HTTP basic-auth.": "List of users and groups for HTTP basic auth.",
+ "Кеш HTTP-ответов в памяти. Для TCP-маршрутов не используется.": "In-memory HTTP response cache. Not used for TCP routes.",
+ "Внешняя программа, которую запускает и перезапускает master-процесс HAProxy.": "External program started and restarted by the HAProxy master process.",
+ "Кольцевой буфер в памяти для логов и трассировок.": "In-memory ring buffer for logs and traces.",
+ "Набор пользовательских страниц ошибок (errorfile) для HTTP-режима.": "Set of custom error pages (errorfile) for HTTP mode.",
+ "Хранилище сертификатов и ключей, на которые ссылаются bind-строки.": "Store of certificates and keys referenced by bind lines.",
+ "SMTP-серверы для почтовых уведомлений об изменении состояния серверов.": "SMTP servers for email alerts about server state changes.",
+ "Приём и пересылка syslog-сообщений через HAProxy.": "Receives and forwards syslog messages through HAProxy.",
+ "Условный блок конфигурации: строки до .endif читаются, только если условие истинно (проверяется при загрузке конфига).": "Conditional config block: lines up to .endif are read only if the condition is true (evaluated when the config loads).",
+ "⚠ Если не закрыть блок **.endif**, то HAProxy не загрузит конфиг.": "⚠ If the block is not closed with **.endif**, HAProxy will not load the config.",
+ "Альтернативная ветка условного блока .if.": "Alternative branch of an .if block.",
+ "Ветка «иначе» условного блока .if.": "“Else” branch of an .if block.",
+ "Закрывает условный блок .if.": "Closes an .if block.",
+ "Выводит сообщение уровня notice при загрузке конфига.": "Prints a notice-level message when the config loads.",
+ "Выводит предупреждение при загрузке конфига.": "Prints a warning when the config loads.",
+ "Выводит ошибку и прерывает загрузку конфига.": "Prints an error and aborts loading the config.",
+ "Выводит диагностическое сообщение (только с ключом -dD).": "Prints a diagnostic message (only with -dD).",
+ "Максимум одновременных соединений: в global — на весь процесс, во frontend — на этот вход. Лишние соединения ждут в очереди ядра.": "Maximum concurrent connections: in global — for the whole process, in a frontend — for this entry point. Extra connections wait in the kernel queue.",
+ "⚠ Если поставить **слишком мало**, то новые клиенты будут ждать или отваливаться по таймауту.": "⚠ If set **too low**, new clients will wait or drop on timeout.",
+ "Число рабочих потоков HAProxy. По умолчанию — по числу доступных CPU.": "Number of HAProxy worker threads. Defaults to the number of available CPUs.",
+ "⚠ Если указать **больше ядер**, чем есть, то потоки конкурируют и задержка растёт.": "⚠ If you set **more than the cores** available, threads compete and latency grows.",
+ "Куда отправлять логи: syslog-адрес или сокет, facility и уровень. «log global» в прокси берёт настройки из global.": "Where to send logs: syslog address or socket, facility and level. “log global” in a proxy takes the settings from global.",
+ "⚠ Если писать лог **каждого соединения** на маленький диск, то syslog быстро заполнит место.": "⚠ If you log **every connection** to a small disk, syslog will fill it up quickly.",
+ "Использовать те же log-цели, что заданы в секции global.": "Use the same log targets as defined in the global section.",
+ "Шаблон строки лога соединения с переменными вида %ci, %ft, %B.": "Connection log line format with variables such as %ci, %ft, %B.",
+ "UNIX-сокет Runtime API: статистика, включение/выключение серверов, веса, таблицы. NodeFlow Agent читает через него метрики и управляет квотами.": "Runtime API UNIX socket: statistics, enabling/disabling servers, weights, tables. NodeFlow Agent reads metrics and enforces quotas through it.",
+ "⚠ Если **удалить или переименовать** /run/haproxy/admin.sock, то метрики, квоты и мягкий reload в NodeFlow перестанут работать.": "⚠ If you **remove or rename** /run/haproxy/admin.sock, metrics, quotas and graceful reload in NodeFlow stop working.",
+ "Сколько ждать команду на stats-сокете, прежде чем закрыть соединение.": "How long to wait for a command on the stats socket before closing the connection.",
+ "Пользователь, от имени которого работает HAProxy после старта.": "User HAProxy runs as after startup.",
+ "Группа, от имени которой работает HAProxy после старта.": "Group HAProxy runs as after startup.",
+ "Файл, в который записывается PID процесса.": "File the process PID is written to.",
+ "Запуск в фоне. Под systemd обычно не нужен: сервис использует master-worker режим.": "Run in the background. Usually not needed under systemd: the service uses master-worker mode.",
+ "Режим master-worker: мастер-процесс перезапускает воркеры при reload без потери сокетов.": "Master-worker mode: the master process restarts workers on reload without losing sockets.",
+ "Переводит процесс в chroot-каталог после старта.": "Moves the process into a chroot directory after startup.",
+ "⚠ Если включить **chroot**, то пути к файлам (acl -f, errorfile) должны быть доступны внутри каталога.": "⚠ If **chroot** is enabled, file paths (acl -f, errorfile) must be reachable inside the directory.",
+ "Сколько старый процесс может дожидаться завершения соединений после reload, прежде чем закрыть их принудительно.": "How long the old process may wait for connections to finish after a reload before closing them forcibly.",
+ "⚠ Если значение **не задано**, то старые процессы с долгими TCP-сессиями живут неограниченно и держат память.": "⚠ If **not set**, old processes with long TCP sessions live indefinitely and hold memory.",
+ "Список шифров TLS ≤1.2 по умолчанию для bind с ssl.": "Default TLS ≤1.2 cipher list for bind lines with ssl.",
+ "Список шифров TLS 1.3 по умолчанию для bind с ssl.": "Default TLS 1.3 cipher suites for bind lines with ssl.",
+ "Опции TLS по умолчанию для bind: минимальная версия, отключение tickets и т.п.": "Default TLS options for bind: minimum version, disabling tickets, etc.",
+ "Список шифров по умолчанию для TLS-соединений к серверам.": "Default cipher list for TLS connections to servers.",
+ "Опции TLS по умолчанию для соединений к серверам.": "Default TLS options for connections to servers.",
+ "Размер pipe-буфера ядра для splice. NodeFlow подбирает его по объёму памяти ноды.": "Kernel pipe buffer size for splice. NodeFlow sizes it from the node memory.",
+ "⚠ Если задать **слишком большой** размер на ноде с малой памятью, то под нагрузкой не хватит RAM.": "⚠ If set **too large** on a low-memory node, RAM will run out under load.",
+ "Размер буфера на соединение. Влияет на память и на максимальный размер заголовков.": "Per-connection buffer size. Affects memory and the maximum header size.",
+ "⚠ Если **увеличить** bufsize, то память на maxconn соединений растёт пропорционально.": "⚠ If you **increase** bufsize, memory for maxconn connections grows proportionally.",
+ "Резерв буфера под перезапись HTTP-заголовков.": "Buffer reserve for rewriting HTTP headers.",
+ "Размер DH-параметров для TLS.": "DH parameter size for TLS.",
+ "Имя этого процесса в секции peers. NodeFlow использует его для переноса stick-table между процессами при reload.": "Name of this process in the peers section. NodeFlow uses it to carry stick-tables across processes on reload.",
+ "Отключает SO_REUSEPORT на слушающих сокетах.": "Disables SO_REUSEPORT on listening sockets.",
+ "Привязка потоков HAProxy к ядрам CPU.": "Pins HAProxy threads to CPU cores.",
+ "Лимит открытых файлов процесса. По умолчанию вычисляется из maxconn.": "Process open-files limit. Computed from maxconn by default.",
+ "Разрешает экспериментальные директивы.": "Allows experimental directives.",
+ "Режим прокси: tcp — прозрачная пересылка байтов (NodeFlow), http — разбор HTTP.": "Proxy mode: tcp — transparent byte forwarding (NodeFlow), http — HTTP parsing.",
+ "⚠ Если frontend и backend в **разных режимах**, то HAProxy откажется загружать конфиг.": "⚠ If the frontend and backend use **different modes**, HAProxy refuses to load the config.",
+ "Сколько ждать установления TCP-соединения с сервером.": "How long to wait for the TCP connection to the server to be established.",
+ "⚠ Если значение **слишком большое**, то клиент долго ждёт при недоступном сервере вместо перехода на другой.": "⚠ If the value is **too large**, the client waits long on an unavailable server instead of moving to another.",
+ "Максимальная пауза без данных со стороны клиента. После неё соединение закрывается.": "Maximum inactivity on the client side. The connection is closed after it.",
+ "⚠ Если поставить **слишком мало** для VPN/прокси-трафика, то простаивающие сессии будут рваться.": "⚠ If set **too low** for VPN/proxy traffic, idle sessions will be cut.",
+ "Максимальная пауза без данных со стороны сервера.": "Maximum inactivity on the server side.",
+ "⚠ Если значение **меньше timeout client**, то длинные сессии обрываются со стороны сервера.": "⚠ If the value is **less than timeout client**, long sessions are cut from the server side.",
+ "Таймаут бездействия для установившегося туннеля (WebSocket, CONNECT, TCP). Заменяет client/server после установления.": "Inactivity timeout for an established tunnel (WebSocket, CONNECT, TCP). Replaces client/server once established.",
+ "Дополнительный таймаут на чтение ответа health-check после соединения.": "Additional timeout for reading the health-check response after connecting.",
+ "Сколько соединение может ждать в очереди, когда у серверов исчерпан maxconn.": "How long a connection may wait in the queue when servers have reached maxconn.",
+ "Таймаут бездействия клиента после полузакрытия соединения (FIN).": "Client inactivity timeout after a half-closed connection (FIN).",
+ "Таймаут бездействия сервера после полузакрытия соединения (FIN).": "Server inactivity timeout after a half-closed connection (FIN).",
+ "Время на получение полного HTTP-запроса (защита от slowloris).": "Time allowed to receive a complete HTTP request (slowloris protection).",
+ "Сколько ждать следующий HTTP-запрос в keep-alive соединении.": "How long to wait for the next HTTP request on a keep-alive connection.",
+ "Интервал периодического перерезолва имён в секции resolvers.": "Interval for periodic re-resolution of names in the resolvers section.",
+ "Сколько ждать ответа DNS-сервера перед повторным запросом.": "How long to wait for a DNS server reply before retrying.",
+ "Расширенный формат лога для TCP: адреса, время, байты, состояние завершения.": "Extended TCP log format: addresses, timings, bytes, termination state.",
+ "Расширенный формат лога для HTTP.": "Extended HTTP log format.",
+ "Не логировать соединения без данных (сканеры портов, проверки балансировщиков).": "Do not log connections without data (port scanners, load balancer probes).",
+ "Пересылка данных клиент→сервер через splice ядра без копирования в user space. Экономит CPU.": "Forwards client→server data with kernel splice, without copying into user space. Saves CPU.",
+ "Пересылка данных сервер→клиент через splice ядра без копирования в user space.": "Forwards server→client data with kernel splice, without copying into user space.",
+ "HAProxy сам решает, когда использовать splice.": "HAProxy decides on its own when to use splice.",
+ "При неудачном подключении разрешает повтор на другой сервер, даже если клиент «прилип» к исходному.": "On a failed connection allows retrying on another server, even if the client is “stuck” to the original one.",
+ "Health-check через TCP-сценарий (tcp-check connect/send/expect). Без правил — просто проверка соединения.": "Health check via a TCP script (tcp-check connect/send/expect). Without rules — just a connection check.",
+ "Health-check HTTP-запросом; сервер считается живым при ответе 2xx/3xx.": "Health check via an HTTP request; the server is considered alive on a 2xx/3xx response.",
+ "Когда все основные серверы недоступны — балансировать между всеми backup-серверами, а не только первым.": "When all primary servers are down, balance across all backup servers instead of only the first one.",
+ "Включить TCP keepalive к клиенту: ядро будет замечать «мёртвые» клиентские соединения.": "Enable TCP keepalive to the client: the kernel will detect “dead” client connections.",
+ "Включить TCP keepalive к серверу.": "Enable TCP keepalive to the server.",
+ "TCP keepalive в обе стороны.": "TCP keepalive in both directions.",
+ "Добавлять заголовок X-Forwarded-For (только HTTP-режим).": "Add the X-Forwarded-For header (HTTP mode only).",
+ "Снимать запрос из очереди, если клиент уже закрыл соединение.": "Drop a request from the queue if the client has already closed the connection.",
+ "Логировать смену результатов health-check.": "Log health-check result changes.",
+ "Отключает опцию, унаследованную из defaults.": "Disables an option inherited from defaults.",
+ "Через сколько простоя ядро начинает слать keepalive-пробы клиенту.": "Idle time after which the kernel starts sending keepalive probes to the client.",
+ "Интервал между keepalive-пробами клиенту.": "Interval between keepalive probes to the client.",
+ "Сколько неотвеченных keepalive-проб до закрытия клиентского соединения.": "Unanswered keepalive probes before the client connection is closed.",
+ "Через сколько простоя ядро начинает слать keepalive-пробы серверу.": "Idle time after which the kernel starts sending keepalive probes to the server.",
+ "Интервал между keepalive-пробами серверу.": "Interval between keepalive probes to the server.",
+ "Сколько неотвеченных keepalive-проб до закрытия серверного соединения.": "Unanswered keepalive probes before the server connection is closed.",
+ "Сколько раз повторять неудачное подключение к серверу.": "How many times to retry a failed connection to a server.",
+ "Алгоритм выбора сервера в backend.": "Server selection algorithm in the backend.",
+ "⚠ Если сменить алгоритм **с source/hash**, то клиенты потеряют привязку к серверам.": "⚠ If you switch the algorithm **from source/hash**, clients lose their server stickiness.",
+ "Как хеш (balance source/hash) отображается на серверы: map-based — быстро, consistent — минимальные перестановки при изменениях.": "How the hash (balance source/hash) maps to servers: map-based — fast, consistent — minimal reshuffling on changes.",
+ "Для consistent-хеша: сервер не получит больше указанного процента от средней нагрузки.": "For a consistent hash: a server gets no more than the given percentage of the average load.",
+ "Адрес и порт, на которых слушает frontend. Можно указать несколько bind.": "Address and port the frontend listens on. Several bind lines are allowed.",
+ "bind [<адрес>]:<порт>[,…] [опции]": "bind []:[,…] [options]",
+ "⚠ Если порт **уже занят** другим процессом, то HAProxy не запустится после reload.": "⚠ If the port is **already in use** by another process, HAProxy will not start after reload.",
+ "Сколько ждать данные клиента для проверки content-правил (например, SNI из TLS ClientHello).": "How long to wait for client data to evaluate content rules (e.g. SNI from the TLS ClientHello).",
+ "⚠ Если убрать **inspect-delay**, то SNI-правила не успеют увидеть ClientHello и трафик уйдёт в default_backend.": "⚠ If you remove **inspect-delay**, SNI rules will not see the ClientHello in time and traffic goes to default_backend.",
+ "Правило над содержимым соединения после inspect-delay: accept, reject, установка лимитов и т.п.": "Rule on connection content after inspect-delay: accept, reject, set limits, etc.",
+ "Правило сразу после accept(), до чтения данных: принять, отклонить, ожидать PROXY-заголовок.": "Rule right after accept(), before reading data: accept, reject, expect a PROXY header.",
+ "Правило после handshake (PROXY/TLS), до чтения данных.": "Rule after the handshake (PROXY/TLS), before reading data.",
+ "Правило над данными от сервера.": "Rule on data coming from the server.",
+ "Шаг TCP health-check: открыть соединение.": "TCP health-check step: open a connection.",
+ "Шаг TCP health-check: отправить строку.": "TCP health-check step: send a string.",
+ "Шаг TCP health-check: ожидаемый ответ.": "TCP health-check step: expected response.",
+ "Отправить соединение в указанный backend, если условие истинно. Правила проверяются по порядку.": "Send the connection to the given backend if the condition is true. Rules are evaluated in order.",
+ "⚠ Если backend **не существует**, то HAProxy не загрузит конфиг.": "⚠ If the backend **does not exist**, HAProxy will not load the config.",
+ "Backend для соединений, не подошедших ни под одно use_backend.": "Backend for connections that match no use_backend rule.",
+ "Именованное условие: выборка (src, req.ssl_sni…) плюс значения для сравнения. Используется в if/unless.": "Named condition: a fetch (src, req.ssl_sni…) plus values to compare. Used in if/unless.",
+ "acl <имя> <выборка> [флаги] <значения>": "acl [flags] ",
+ "Сервер назначения в backend: имя, адрес:порт и опции проверок, веса, PROXY protocol.": "Destination server in a backend: name, address:port and options for checks, weights, PROXY protocol.",
+ "server <имя> <адрес>[:<порт>] [опции]": "server [:] [options]",
+ "⚠ Если **переименовать** сервер nf_srv_*, то NodeFlow потеряет его метрики и квоты.": "⚠ If you **rename** an nf_srv_* server, NodeFlow loses its metrics and quotas.",
+ "Создаёт N серверов из одного DNS-имени: каждый слот получает один из адресов записи.": "Creates N servers from one DNS name: each slot gets one of the record addresses.",
+ "server-template <префикс> <кол-во> :<порт> [опции]": "server-template : [options]",
+ "Опции по умолчанию для всех server в секции.": "Default options for all server lines in the section.",
+ "default-server [опции]": "default-server [options]",
+ "Таблица в памяти для привязки клиентов к серверам или счётчиков (скорость, соединения).": "In-memory table for binding clients to servers or for counters (rate, connections).",
+ "⚠ Если **size** мал для числа клиентов, то старые записи вытесняются и привязка теряется.": "⚠ If **size** is too small for the number of clients, old entries are evicted and stickiness is lost.",
+ "Запомнить в stick-table, на какой сервер ушёл клиент, и отправлять его туда же.": "Remember in the stick-table which server a client went to and keep sending it there.",
+ "Искать клиента в stick-table без записи.": "Look up the client in the stick-table without storing.",
+ "Записывать клиента в stick-table без поиска.": "Store the client in the stick-table without looking up.",
+ "Фильтр ограничения скорости входящего (upload) трафика. Лимит применяется через tcp-request content set-bandwidth-limit.": "Rate-limit filter for incoming (upload) traffic. The limit is applied with tcp-request content set-bandwidth-limit.",
+ "Фильтр ограничения скорости исходящего (download) трафика.": "Rate-limit filter for outgoing (download) traffic.",
+ "Подключает фильтр потока данных (bwlim-in/out, compression, trace…).": "Attaches a data stream filter (bwlim-in/out, compression, trace…).",
+ "filter <тип> [параметры]": "filter [parameters]",
+ "Файл ответа для HTTP-кода ошибки.": "Response file for an HTTP error code.",
+ "Правило обработки HTTP-запроса (только mode http).": "HTTP request processing rule (mode http only).",
+ "Правило обработки HTTP-ответа (только mode http).": "HTTP response processing rule (mode http only).",
+ "Шаг HTTP health-check.": "HTTP health-check step.",
+ "Нагрузка на backend, при которой серверы используют полный maxconn (для динамического minconn).": "Backend load at which servers use their full maxconn (for dynamic minconn).",
+ "Длина очереди ядра для ещё не принятых соединений.": "Kernel queue length for not yet accepted connections.",
+ "Ограничение новых сессий в секунду на frontend.": "Limit of new sessions per second on the frontend.",
+ "Текстовое описание секции (видно в статистике).": "Text description of the section (shown in statistics).",
+ "Секция загружается, но не работает.": "The section is loaded but inactive.",
+ "Явно включает секцию (по умолчанию).": "Explicitly enables the section (default).",
+ "URI для внешних health-проверок самого HAProxy.": "URI for external health checks of HAProxy itself.",
+ "Исходный адрес для соединений к серверам.": "Source address for connections to servers.",
+ "DNS-сервер для этой секции resolvers.": "DNS server for this resolvers section.",
+ "Сколько раз повторять DNS-запрос, прежде чем считать резолв неудачным.": "How many times to retry a DNS query before treating resolution as failed.",
+ "Сколько хранить последний результат DNS для заданного статуса ответа (valid, nx, timeout…).": "How long to keep the last DNS result for a given response status (valid, nx, timeout…).",
+ "⚠ Если **hold valid** слишком велик, то смена IP у бэкенда подхватится с задержкой.": "⚠ If **hold valid** is too long, a backend IP change is picked up late.",
+ "Взять nameserver-ы из /etc/resolv.conf.": "Take nameservers from /etc/resolv.conf.",
+ "Максимальный размер DNS-ответа по UDP (EDNS0).": "Maximum DNS response size over UDP (EDNS0).",
+ "Участник синхронизации stick-table.": "Stick-table synchronization peer.",
+ "Включает health-check сервера. Упавший сервер исключается из балансировки.": "Enables server health checks. A failed server is removed from load balancing.",
+ "⚠ Если **check** не указан, то HAProxy шлёт трафик на сервер, даже если он недоступен.": "⚠ If **check** is not set, HAProxy sends traffic to the server even when it is down.",
+ "Интервал между health-check.": "Interval between health checks.",
+ "inter <время> # по умолчанию 2s": "inter # default 2s",
+ "Интервал проверок во время перехода состояния.": "Check interval during a state transition.",
+ "Интервал проверок, пока сервер лежит.": "Check interval while the server is down.",
+ "Сколько подряд неудачных проверок до статуса DOWN.": "Consecutive failed checks before the server goes DOWN.",
+ "fall <число> # по умолчанию 3": "fall # default 3",
+ "Сколько подряд успешных проверок до статуса UP.": "Consecutive successful checks before the server goes UP.",
+ "rise <число> # по умолчанию 2": "rise # default 2",
+ "Вес сервера при балансировке: доля трафика пропорциональна весу. 0 — сервер не получает новых соединений.": "Server weight in load balancing: the traffic share is proportional to the weight. 0 — the server gets no new connections.",
+ "⚠ Если NodeFlow управляет весами (leastping), то ручные **weight** перезапишутся Agent-ом.": "⚠ If NodeFlow manages weights (leastping), manual **weight** values are overwritten by the Agent.",
+ "Резервный сервер: получает трафик, только когда все основные недоступны.": "Backup server: gets traffic only when all primary servers are down.",
+ "Отправлять серверу PROXY protocol v1 (текстовый) с реальным адресом клиента.": "Send PROXY protocol v1 (text) with the real client address to the server.",
+ "⚠ Если сервер **не ждёт** PROXY-заголовок, то все соединения к нему будут ломаться.": "⚠ If the server **does not expect** a PROXY header, all connections to it will break.",
+ "Отправлять серверу PROXY protocol v2 (бинарный) с реальным адресом клиента.": "Send PROXY protocol v2 (binary) with the real client address to the server.",
+ "⚠ Если сервер **не ждёт** PROXY v2, то все соединения к нему будут ломаться.": "⚠ If the server **does not expect** PROXY v2, all connections to it will break.",
+ "Слать PROXY-заголовок и в health-check, когда сервер требует его на каждом соединении.": "Send the PROXY header in health checks too, when the server requires it on every connection.",
+ "⚠ Если сервер ждёт PROXY, а **check-send-proxy** не указан, то проверки падают и сервер помечается DOWN.": "⚠ If the server expects PROXY but **check-send-proxy** is not set, checks fail and the server is marked DOWN.",
+ "Секция resolvers для перерезолва DNS-имени сервера во время работы.": "Resolvers section used to re-resolve the server DNS name at runtime.",
+ "Как получить адрес при старте: last — из state-файла, libc — системным резолвером, none — стартовать без адреса.": "How to get the address at startup: last — from the state file, libc — via the system resolver, none — start without an address.",
+ "⚠ Если **none** не указан, а DNS недоступен при старте, то HAProxy не запустится.": "⚠ If **none** is not set and DNS is unavailable at startup, HAProxy will not start.",
+ "Максимум одновременных соединений к этому серверу; остальные ждут в очереди backend.": "Maximum concurrent connections to this server; the rest wait in the backend queue.",
+ "Плавный разгон веса сервера после возврата в UP, чтобы не завалить его трафиком.": "Gradual weight ramp-up after the server comes back UP, so it is not flooded with traffic.",
+ "Опции DNS-резолва сервера: prevent-dup-ip — не давать двум слотам один IP, allow-dup-ip, ignore-weight.": "Server DNS resolution options: prevent-dup-ip — never give two slots the same IP, allow-dup-ip, ignore-weight.",
+ "Предпочитаемое семейство адресов при резолве.": "Preferred address family for resolution.",
+ "Что использовать как ключ сервера для consistent-хеша: id, addr или addr-port. addr сохраняет привязку при смене порядка слотов.": "What to use as the server key for a consistent hash: id, addr or addr-port. addr keeps stickiness when slot order changes.",
+ "Сервер в режиме обслуживания (MAINT) с момента запуска.": "Server is in maintenance (MAINT) from startup.",
+ "Шифровать соединение к серверу TLS.": "Encrypt the connection to the server with TLS.",
+ "Проверка сертификата сервера.": "Server certificate verification.",
+ "SNI для TLS-соединения к серверу.": "SNI for the TLS connection to the server.",
+ "Порт для health-check, если отличается от порта трафика.": "Health-check port, if different from the traffic port.",
+ "Адрес для health-check, если отличается от адреса трафика.": "Health-check address, if different from the traffic address.",
+ "Включить agent-check: внешний агент сообщает вес и состояние сервера.": "Enable agent-check: an external agent reports the server weight and state.",
+ "Что делать при падении сервера: shutdown-sessions — закрыть его сессии.": "What to do when the server goes down: shutdown-sessions — close its sessions.",
+ "Что делать при возврате сервера: shutdown-backup-sessions.": "What to do when the server comes back: shutdown-backup-sessions.",
+ "Пассивная проверка здоровья по ошибкам реального трафика.": "Passive health check based on errors in real traffic.",
+ "Брать состояние из другого сервера вместо своих проверок.": "Take the state from another server instead of running its own checks.",
+ "Значение cookie для привязки в HTTP-режиме.": "Cookie value for stickiness in HTTP mode.",
+ "Числовой идентификатор сервера.": "Numeric server ID.",
+ "Требовать PROXY protocol (v1/v2) от каждого входящего соединения — адрес клиента берётся из заголовка.": "Require PROXY protocol (v1/v2) on every incoming connection — the client address is taken from the header.",
+ "⚠ Если клиенты подключаются **напрямую**, без PROXY-заголовка, то их соединения будут отклонены.": "⚠ If clients connect **directly**, without a PROXY header, their connections will be rejected.",
+ "Слушать IPv4 и IPv6 на одном сокете «::».": "Listen on IPv4 and IPv6 on a single “::” socket.",
+ "Сокет «::» принимает только IPv6.": "The “::” socket accepts IPv6 only.",
+ "В HAProxy это не опция bind: SO_REUSEPORT включён по умолчанию, а отключается глобальной директивой noreuseport.": "Not a bind option in HAProxy: SO_REUSEPORT is on by default and is disabled with the global noreuseport directive.",
+ "Слушать нелокальный адрес (TPROXY).": "Listen on a non-local address (TPROXY).",
+ "Разрешить TCP Fast Open для входящих соединений.": "Allow TCP Fast Open for incoming connections.",
+ "Будить HAProxy только когда клиент прислал данные.": "Wake HAProxy only when the client has sent data.",
+ "Слушать только на указанном сетевом интерфейсе.": "Listen only on the given network interface.",
+ "Имя слушающего сокета в статистике.": "Listening socket name in statistics.",
+ "Терминировать TLS на этом bind.": "Terminate TLS on this bind.",
+ "⚠ Если включить **ssl** на SNI-маршрутизации NodeFlow, то HAProxy расшифрует трафик вместо прозрачной пересылки.": "⚠ If you enable **ssl** on NodeFlow SNI routing, HAProxy decrypts traffic instead of forwarding it transparently.",
+ "Сертификат (PEM с ключом) или каталог сертификатов для ssl.": "Certificate (PEM with key) or certificate directory for ssl.",
+ "Список ALPN-протоколов для TLS.": "List of ALPN protocols for TLS.",
+ "Какими потоками обслуживать этот bind.": "Which threads serve this bind.",
+ "Очередь ядра для этого сокета.": "Kernel queue for this socket.",
+ "Максимум одновременных соединений через этот bind.": "Maximum concurrent connections through this bind.",
+ "Права на UNIX-сокет (восьмеричные).": "UNIX socket permissions (octal).",
+ "Владелец UNIX-сокета.": "UNIX socket owner.",
+ "Группа UNIX-сокета.": "UNIX socket group.",
+ "Уровень доступа к stats-сокету: user, operator или admin.": "Stats socket access level: user, operator or admin.",
+ "⚠ Если понизить **level admin**, то NodeFlow Agent не сможет управлять весами и квотами.": "⚠ If you lower **level admin**, NodeFlow Agent cannot manage weights and quotas.",
+ "Полный уровень доступа к Runtime API.": "Full access level to the Runtime API.",
+ "Разрешить передачу слушающих сокетов новому процессу при reload — соединения не теряются.": "Allow passing listening sockets to the new process on reload — connections are not lost.",
+ "⚠ Если убрать **expose-fd listeners**, то при reload возможны отказы в соединении.": "⚠ If you remove **expose-fd listeners**, connections may be refused during reload.",
+ "Аргумент expose-fd: передавать слушающие сокеты.": "expose-fd argument: pass the listening sockets.",
+ "Слушать в указанном network namespace.": "Listen in the given network namespace.",
+ "По очереди с учётом весов; веса можно менять на лету.": "Round robin with weights; weights can be changed on the fly.",
+ "По очереди с учётом весов, без ограничения на число серверов; веса не меняются на лету.": "Round robin with weights, without a limit on server count; weights cannot be changed on the fly.",
+ "Сервер с наименьшим числом активных соединений (с учётом веса). Подходит для долгих TCP-сессий.": "Server with the fewest active connections (weighted). Suits long TCP sessions.",
+ "Первый сервер с свободными слотами maxconn; остальные включаются по мере заполнения.": "First server with free maxconn slots; the others come into play as it fills up.",
+ "Хеш IP клиента: один клиент попадает на один сервер, пока набор серверов не меняется.": "Client IP hash: a client lands on the same server as long as the server set does not change.",
+ "⚠ Если клиенты за **общим NAT**, то они все уйдут на один сервер.": "⚠ If clients are behind a **shared NAT**, they all go to one server.",
+ "Случайный выбор; random(N) — лучший из N случайных по числу соединений («power of two choices»).": "Random choice; random(N) — the best of N random servers by connection count (“power of two choices”).",
+ "Хеш произвольного выражения, например src,ipmask(32,64) — привязка по подсети.": "Hash of an arbitrary expression, e.g. src,ipmask(32,64) — stickiness by subnet.",
+ "Хеш URI запроса (HTTP).": "Hash of the request URI (HTTP).",
+ "Хеш HTTP-заголовка.": "Hash of an HTTP header.",
+ "Хеш RDP-cookie.": "Hash of the RDP cookie.",
+ "Статическая таблица: быстро, но при изменении набора серверов перераспределяется большинство клиентов.": "Static table: fast, but when the server set changes most clients are redistributed.",
+ "Кольцо хешей: при добавлении/удалении сервера переезжает лишь малая доля клиентов.": "Hash ring: adding or removing a server moves only a small share of clients.",
+ "Хеш-функция sdbm (по умолчанию).": "sdbm hash function (default).",
+ "Хеш-функция djb2.": "djb2 hash function.",
+ "Хеш-функция wt6.": "wt6 hash function.",
+ "Хеш-функция crc32.": "crc32 hash function.",
+ "Без хеш-функции: значение используется как есть.": "No hash function: the value is used as is.",
+ "Дополнительное перемешивание хеша — равномернее распределяет похожие ключи (соседние IP).": "Extra hash mixing — spreads similar keys (neighboring IPs) more evenly.",
+ "Тип ключа таблицы: ip, ipv6, integer, string, binary.": "Table key type: ip, ipv6, integer, string, binary.",
+ "Ключ — IPv4-адрес клиента.": "Key — client IPv4 address.",
+ "Ключ — IPv6-адрес (IPv4 хранится как отображённый).": "Key — IPv6 address (IPv4 is stored as mapped).",
+ "Ключ — целое число.": "Key — integer.",
+ "Ключ — строка.": "Key — string.",
+ "Максимум записей; суффиксы k, m, g.": "Maximum entries; suffixes k, m, g.",
+ "⚠ Если таблица **переполнена**, то вытесняются самые старые записи.": "⚠ If the table is **full**, the oldest entries are evicted.",
+ "Время жизни неиспользуемой записи.": "Lifetime of an unused entry.",
+ "Секция peers для синхронизации таблицы; в NodeFlow сохраняет привязки при reload.": "Peers section for table synchronization; in NodeFlow it keeps stickiness across reloads.",
+ "Какие счётчики хранить в записи.": "Which counters to store in an entry.",
+ "Не вытеснять записи при переполнении.": "Do not evict entries when the table is full.",
+ "Скорость входящих байт за период — основа лимита upload.": "Incoming bytes rate over a period — basis of the upload limit.",
+ "Скорость исходящих байт за период — основа лимита download.": "Outgoing bytes rate over a period — basis of the download limit.",
+ "Текущее число соединений ключа.": "Current connection count for the key.",
+ "Скорость новых соединений ключа.": "New connection rate for the key.",
+ "ID сервера, к которому привязан ключ.": "ID of the server the key is bound to.",
+ "Принять соединение и прекратить проверку остальных правил этого уровня.": "Accept the connection and stop evaluating the remaining rules of this level.",
+ "Закрыть соединение.": "Close the connection.",
+ "Ожидать PROXY protocol заголовок на этом соединении.": "Expect a PROXY protocol header on this connection.",
+ "⚠ Если заголовок ждётся от **всех** источников, то прямые клиенты без PROXY не подключатся.": "⚠ If the header is expected from **all** sources, direct clients without PROXY cannot connect.",
+ "Уровень правила: сразу после accept().": "Rule level: right after accept().",
+ "Включить фильтр bwlim для соединения, опционально с лимитом и периодом.": "Enable the bwlim filter for the connection, optionally with a limit and period.",
+ "Учитывать соединение в stick-table (счётчик sc0).": "Track the connection in the stick-table (counter sc0).",
+ "Учитывать соединение в stick-table (счётчик sc1).": "Track the connection in the stick-table (counter sc1).",
+ "Молча сбросить соединение без RST клиенту.": "Silently drop the connection without sending RST to the client.",
+ "Записать значение в переменную.": "Store a value in a variable.",
+ "Уровень правил над содержимым (после inspect-delay).": "Rule level on content (after inspect-delay).",
+ "Уровень правил сразу после accept().": "Rule level right after accept().",
+ "Уровень правил после handshake.": "Rule level after the handshake.",
+ "Ограничение скорости входящего (upload) трафика.": "Rate limit for incoming (upload) traffic.",
+ "Ограничение скорости исходящего (download) трафика.": "Rate limit for outgoing (download) traffic.",
+ "Лимит скорости в байтах в секунду (суффиксы k, m, g).": "Rate limit in bytes per second (suffixes k, m, g).",
+ "Выражение-ключ, по которому считается лимит (обычно адрес клиента).": "Key expression the limit is counted by (usually the client address).",
+ "Stick-table, где хранится текущая скорость по ключу.": "Stick-table holding the current rate per key.",
+ "Минимальный размер порции данных, который фильтр пропускает за раз.": "Minimum data chunk the filter lets through at once.",
+ "Период усреднения скорости.": "Rate averaging period.",
+ "Лимит по умолчанию для фильтра без ключа.": "Default limit for a filter without a key.",
+ "Период по умолчанию.": "Default period.",
+ "Сколько использовать успешный ответ DNS до обновления.": "How long to use a successful DNS answer before refreshing.",
+ "Сколько держать старый адрес при ответе NXDOMAIN.": "How long to keep the old address on an NXDOMAIN answer.",
+ "Сколько держать адрес, исчезнувший из ответа DNS.": "How long to keep an address that disappeared from the DNS answer.",
+ "Сколько держать старый адрес при таймауте DNS.": "How long to keep the old address on a DNS timeout.",
+ "Сколько держать старый адрес при ответе REFUSED.": "How long to keep the old address on a REFUSED answer.",
+ "Сколько держать старый адрес при прочих ошибках DNS.": "How long to keep the old address on other DNS errors.",
+ "Использовать log-цели из секции global.": "Use log targets from the global section.",
+ "Уровень syslog notice: только значимые события (смена состояния серверов).": "Syslog level notice: only significant events (server state changes).",
+ "Уровень syslog info: включает лог каждого соединения.": "Syslog level info: includes a log line per connection.",
+ "Syslog facility local0 — лог соединений HAProxy.": "Syslog facility local0 — HAProxy connection log.",
+ "Прозрачная TCP-пересылка без разбора протокола. Так работают все маршруты NodeFlow.": "Transparent TCP forwarding without protocol parsing. All NodeFlow routes work this way.",
+ "Разбор HTTP: заголовки, http-request правила, cookies.": "HTTP parsing: headers, http-request rules, cookies.",
+ "Взять последний известный адрес из state-файла.": "Take the last known address from the state file.",
+ "Резолвить системным резолвером при старте.": "Resolve with the system resolver at startup.",
+ "Разрешить старт без адреса: сервер в MAINT, пока DNS не ответит.": "Allow starting without an address: the server stays in MAINT until DNS answers.",
+ "Не назначать один и тот же IP двум слотам server-template — каждый адрес DNS-записи получает свой слот.": "Never assign the same IP to two server-template slots — each DNS record address gets its own slot.",
+ "Разрешить нескольким слотам получить один IP.": "Allow several slots to get the same IP.",
+ "Не брать вес из SRV-записей DNS.": "Do not take weights from DNS SRV records.",
+ "Ключ consistent-хеша — числовой ID сервера.": "Consistent-hash key — numeric server ID.",
+ "Ключ consistent-хеша — IP сервера: привязка клиентов сохраняется, даже если слот сменил адрес.": "Consistent-hash key — server IP: client stickiness is kept even if the slot changed its address.",
+ "Ключ consistent-хеша — IP и порт сервера.": "Consistent-hash key — server IP and port.",
+ "Начало анонимного ACL: условие записывается прямо в правиле, без отдельной строки acl.": "Start of an anonymous ACL: the condition is written inline in the rule, without a separate acl line.",
+ "if { <выборка> [флаги] <значения> }": "if { [flags] }",
+ "⚠ Если забыть **пробел** после { или перед }, то HAProxy не распознает условие.": "⚠ If you forget the **space** after { or before }, HAProxy will not recognize the condition.",
+ "Конец анонимного ACL.": "End of an anonymous ACL.",
+ "Правило срабатывает, только если условие истинно. Несколько ACL подряд — «И», «||» или or — «ИЛИ».": "The rule applies only if the condition is true. Several ACLs in a row mean “AND”, “||” or or mean “OR”.",
+ "Правило срабатывает, только если условие ложно.": "The rule applies only if the condition is false.",
+ "Логическое «ИЛИ» между ACL в условии.": "Logical “OR” between ACLs in a condition.",
+ "Отрицание ACL.": "ACL negation.",
+ "Выборка: IP-адрес клиента (после PROXY protocol — реальный адрес из заголовка).": "Fetch: client IP address (after PROXY protocol — the real address from the header).",
+ "Выборка: локальный IP, на который пришло соединение.": "Fetch: local IP the connection arrived on.",
+ "Выборка: локальный порт, на который пришло соединение.": "Fetch: local port the connection arrived on.",
+ "Выборка: порт клиента.": "Fetch: client port.",
+ "Выборка: имя сервера (SNI) из TLS ClientHello без расшифровки. Требует tcp-request inspect-delay.": "Fetch: server name (SNI) from the TLS ClientHello without decryption. Requires tcp-request inspect-delay.",
+ "⚠ Если клиент **не передаёт SNI** (старый клиент, IP вместо домена), то сработает default_backend.": "⚠ If the client **does not send SNI** (old client, IP instead of a domain), default_backend applies.",
+ "Устаревшее имя req.ssl_sni: SNI из TLS ClientHello.": "Legacy name of req.ssl_sni: SNI from the TLS ClientHello.",
+ "Выборка: тип TLS handshake-сообщения; 1 — ClientHello. Используется, чтобы принять соединение, как только SNI получен.": "Fetch: TLS handshake message type; 1 — ClientHello. Used to accept the connection as soon as SNI is received.",
+ "Выборка: сколько байт запроса уже получено в буфере.": "Fetch: how many request bytes are already in the buffer.",
+ "Выборка: версия TLS из ClientHello.": "Fetch: TLS version from the ClientHello.",
+ "Выборка: SNI при TLS-терминации на bind ssl.": "Fetch: SNI with TLS termination on bind ssl.",
+ "Скорость исходящих байт по счётчику sc0.": "Outgoing bytes rate for counter sc0.",
+ "Преобразователь: маскирует адрес до сети (IPv4-префикс, IPv6-префикс). src,ipmask(32,64) — привязка по /64 для IPv6.": "Converter: masks the address down to a network (IPv4 prefix, IPv6 prefix). src,ipmask(32,64) — stickiness per /64 for IPv6.",
+ "Флаг ACL: сравнение без учёта регистра.": "ACL flag: case-insensitive match.",
+ "Флаг ACL: метод сравнения — str, beg, end, sub, reg, found, ip, int…": "ACL flag: match method — str, beg, end, sub, reg, found, ip, int…",
+ "Флаг ACL: загрузить значения из файла (по одному на строку).": "ACL flag: load values from a file (one per line).",
+ "⚠ Если **файла нет** на ноде, то HAProxy не загрузит конфиг.": "⚠ If the **file is missing** on the node, HAProxy will not load the config.",
+ "Флаг ACL: не резолвить DNS-имена в значениях.": "ACL flag: do not resolve DNS names in values.",
+ "Функция условия .if: истинна, если возможность (например SPLICE) поддерживается и не выключена.": ".if condition function: true if the feature (e.g. SPLICE) is supported and not disabled.",
+ "Функция условия .if: истинна, если переменная окружения определена.": ".if condition function: true if the environment variable is defined.",
+ "Функция условия .if: версия HAProxy не ниже указанной.": ".if condition function: HAProxy version is at least the given one.",
+ "Функция условия .if: сборка поддерживает возможность.": ".if condition function: the build supports the feature.",
+ "микросекунд": "microseconds",
+ "миллисекунд": "milliseconds",
+ "секунд": "seconds",
+ "минут": "minutes",
+ "часов": "hours",
+ "дней": "days",
+ "defaults [<имя>]": "defaults []",
+ "frontend <имя>": "frontend ",
+ "backend <имя>": "backend ",
+ "listen <имя>": "listen ",
+ "resolvers <имя>": "resolvers ",
+ "peers <имя>": "peers ",
+ "userlist <имя>": "userlist ",
+ "cache <имя>": "cache ",
+ "program <имя>": "program ",
+ "ring <имя>": "ring ",
+ "http-errors <имя>": "http-errors ",
+ "crt-store [<имя>]": "crt-store []",
+ "mailers <имя>": "mailers ",
+ "log-forward <имя>": "log-forward ",
+ ".if <условие> … [.elif …] [.else] .endif": ".if … [.elif …] [.else] .endif",
+ ".elif <условие>": ".elif ",
+ ".notice \"<текст>\"": ".notice \"\"",
+ ".warning \"<текст>\"": ".warning \"\"",
+ ".alert \"<текст>\"": ".alert \"\"",
+ ".diag \"<текст>\"": ".diag \"\"",
+ "maxconn <число>": "maxconn ",
+ "nbthread <число>": "nbthread ",
+ "log <адрес> [len ] [<уровень>] | log global": "log [len ] [] | log global",
+ "log-format \"<формат>\"": "log-format \"\"",
+ "stats socket <путь> [mode <права>] [level <уровень>] [expose-fd listeners]": "stats socket [mode ] [level ] [expose-fd listeners]",
+ "stats timeout <время>": "stats timeout ",
+ "user <имя>": "user ",
+ "group <имя>": "group ",
+ "pidfile <путь>": "pidfile ",
+ "chroot <каталог>": "chroot ",
+ "hard-stop-after <время>": "hard-stop-after ",
+ "ssl-default-bind-ciphers <список>": "ssl-default-bind-ciphers ",
+ "ssl-default-bind-ciphersuites <список>": "ssl-default-bind-ciphersuites ",
+ "ssl-default-server-ciphers <список>": "ssl-default-server-ciphers ",
+ "ssl-default-server-options <опции>": "ssl-default-server-options ",
+ "tune.pipesize <байт>": "tune.pipesize ",
+ "tune.bufsize <байт>": "tune.bufsize ",
+ "tune.maxrewrite <байт>": "tune.maxrewrite ",
+ "tune.ssl.default-dh-param <бит>": "tune.ssl.default-dh-param ",
+ "localpeer <имя>": "localpeer ",
+ "cpu-map [auto:]<поток> ": "cpu-map [auto:] ",
+ "ulimit-n <число>": "ulimit-n ",
+ "timeout connect <время>": "timeout connect ",
+ "timeout client <время>": "timeout client ",
+ "timeout server <время>": "timeout server ",
+ "timeout tunnel <время>": "timeout tunnel ",
+ "timeout check <время>": "timeout check ",
+ "timeout queue <время>": "timeout queue ",
+ "timeout client-fin <время>": "timeout client-fin ",
+ "timeout server-fin <время>": "timeout server-fin ",
+ "timeout http-request <время>": "timeout http-request ",
+ "timeout http-keep-alive <время>": "timeout http-keep-alive ",
+ "timeout resolve <время>": "timeout resolve ",
+ "timeout retry <время>": "timeout retry ",
+ "option redispatch [<интервал>]": "option redispatch [