feat: add server extras configuration for trusted proxies and discovery settings

- Introduced new visual configuration fields for trusted proxies and discovery options.
- Implemented validation for trusted proxies and discovery service type.
- Updated localization files for English, Russian, Simplified Chinese, and Traditional Chinese to include new fields and validation messages.
- Enhanced the visual configuration hooks to handle new server fields and validation errors.
- Added tests for server configuration notifications and UI components to ensure proper functionality and localization.
This commit is contained in:
Supra4E8C
2026-09-30 18:50:08 +08:00
parent 254594aa5c
commit 9247abd944
15 changed files with 1144 additions and 2 deletions
@@ -1,13 +1,24 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Collapsible } from '@/components/ui/Collapsible';
import { Input } from '@/components/ui/Input';
import { CONFIG_TAB_ICONS, SECTION_INDEX_LABELS } from '../../constants';
import type { ConfigSectionProps } from '../../types';
import { SectionCard } from '../SectionCard';
import { Divider, FieldAnchor, FieldGrid, FieldStack, ToggleRow } from '../fields/FieldPrimitives';
import {
Divider,
FieldAnchor,
FieldGrid,
FieldShell,
FieldStack,
ToggleRow,
} from '../fields/FieldPrimitives';
import { ApiKeysField, HostField, PortField } from '../fields/sharedFields';
import { getValidationMessage } from '../blocks/shared';
import { StringListEditor } from '../blocks/StringListEditor';
import { SectionDiscovery } from './SectionDiscovery';
const Icon = CONFIG_TAB_ICONS.connectivity;
/** 01 接入与认证:服务地址、端口、认证目录、API 密钥 + TLS / 远程管理折叠组。 */
@@ -19,6 +30,8 @@ export function SectionConnectivity({
onChange,
}: ConfigSectionProps) {
const { t } = useTranslation();
const id = useId();
const trustedProxiesError = getValidationMessage(t, validationErrors?.trustedProxies);
const portError = getValidationMessage(t, validationErrors?.port);
return (
@@ -48,6 +61,32 @@ export function SectionConnectivity({
<ApiKeysField values={values} disabled={disabled} onChange={onChange} />
<FieldAnchor fieldId="trustedProxies">
<FieldShell
label={t('config_management.visual.serverExtras.trustedProxies.label')}
labelId={`${id}-trustedProxies-label`}
hint={t('config_management.visual.serverExtras.trustedProxies.hint')}
hintId={`${id}-trustedProxies-hint`}
error={trustedProxiesError}
errorId={`${id}-trustedProxies-error`}
>
<div
role="group"
aria-labelledby={`${id}-trustedProxies-label`}
aria-describedby={`${id}-trustedProxies-hint${trustedProxiesError ? ` ${id}-trustedProxies-error` : ''}`}
aria-invalid={Boolean(trustedProxiesError)}
>
<StringListEditor
value={values.trustedProxies}
disabled={disabled}
placeholder="192.168.0.0/24"
inputAriaLabel={t('config_management.visual.serverExtras.trustedProxies.label')}
onChange={(trustedProxies) => onChange({ trustedProxies })}
/>
</div>
</FieldShell>
</FieldAnchor>
<Collapsible
label={t('config_management.visual.sections.tls.title')}
hint={t('config_management.visual.sections.tls.description')}
@@ -152,6 +191,12 @@ export function SectionConnectivity({
</FieldGrid>
</FieldStack>
</Collapsible>
<SectionDiscovery
values={values}
validationErrors={validationErrors}
disabled={disabled}
onChange={onChange}
/>
</FieldStack>
</SectionCard>
);
@@ -0,0 +1,152 @@
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Collapsible } from '@/components/ui/Collapsible';
import { Input } from '@/components/ui/Input';
import type { ConfigSectionProps } from '../../types';
import { FieldAnchor, FieldShell, FieldStack, ToggleRow } from '../fields/FieldPrimitives';
import { StringListEditor } from '../blocks/StringListEditor';
import { getValidationMessage } from '../blocks/shared';
/** Kept mounted inside native details so search can reveal every discovery field. */
export function SectionDiscovery({
values,
validationErrors,
disabled,
onChange,
}: ConfigSectionProps) {
const { t } = useTranslation();
const id = useId();
const serviceTypeError = getValidationMessage(t, validationErrors?.discoveryServiceType);
return (
<Collapsible
label={t('config_management.visual.serverExtras.discoveryTitle')}
hint={t('config_management.visual.serverExtras.discoveryHint')}
defaultOpen={false}
>
<FieldStack>
<FieldAnchor fieldId="discoveryEnabled">
<ToggleRow
title={t('config_management.visual.serverExtras.discoveryEnabled.label')}
description={t('config_management.visual.serverExtras.discoveryEnabled.hint')}
checked={values.discoveryEnabled}
disabled={disabled}
onChange={(discoveryEnabled) => onChange({ discoveryEnabled })}
/>
</FieldAnchor>
<FieldAnchor fieldId="discoveryServiceName">
<Input
type="text"
label={t('config_management.visual.serverExtras.discoveryServiceName.label')}
hint={t('config_management.visual.serverExtras.discoveryServiceName.hint')}
value={values.discoveryServiceName}
disabled={disabled}
onChange={(event) => onChange({ discoveryServiceName: event.target.value })}
/>
</FieldAnchor>
<FieldAnchor fieldId="discoveryServiceType">
<Input
type="text"
label={t('config_management.visual.serverExtras.discoveryServiceType.label')}
hint={t('config_management.visual.serverExtras.discoveryServiceType.hint')}
placeholder="_ai-gateway._tcp"
error={serviceTypeError}
value={values.discoveryServiceType}
disabled={disabled}
onChange={(event) => onChange({ discoveryServiceType: event.target.value })}
/>
</FieldAnchor>
<FieldAnchor fieldId="discoverySubtypes">
<FieldShell
label={t('config_management.visual.serverExtras.discoverySubtypes.label')}
labelId={`${id}-discoverySubtypes-label`}
hint={t('config_management.visual.serverExtras.discoverySubtypes.hint')}
hintId={`${id}-discoverySubtypes-hint`}
>
<div
role="group"
aria-labelledby={`${id}-discoverySubtypes-label`}
aria-describedby={`${id}-discoverySubtypes-hint`}
>
<StringListEditor
value={values.discoverySubtypes}
disabled={disabled}
placeholder="_responses"
inputAriaLabel={t('config_management.visual.serverExtras.discoverySubtypes.label')}
onChange={(discoverySubtypes) => onChange({ discoverySubtypes })}
/>
</div>
</FieldShell>
</FieldAnchor>
<FieldAnchor fieldId="discoveryInterfacesInclude">
<FieldShell
label={t('config_management.visual.serverExtras.discoveryInterfacesInclude.label')}
labelId={`${id}-discoveryInterfacesInclude-label`}
hint={t('config_management.visual.serverExtras.discoveryInterfacesInclude.hint')}
hintId={`${id}-discoveryInterfacesInclude-hint`}
>
<div
role="group"
aria-labelledby={`${id}-discoveryInterfacesInclude-label`}
aria-describedby={`${id}-discoveryInterfacesInclude-hint`}
>
<StringListEditor
value={values.discoveryInterfacesInclude}
disabled={disabled}
placeholder="en*"
inputAriaLabel={t(
'config_management.visual.serverExtras.discoveryInterfacesInclude.label'
)}
onChange={(discoveryInterfacesInclude) => onChange({ discoveryInterfacesInclude })}
/>
</div>
</FieldShell>
</FieldAnchor>
<FieldAnchor fieldId="discoveryInterfacesExclude">
<FieldShell
label={t('config_management.visual.serverExtras.discoveryInterfacesExclude.label')}
labelId={`${id}-discoveryInterfacesExclude-label`}
hint={t('config_management.visual.serverExtras.discoveryInterfacesExclude.hint')}
hintId={`${id}-discoveryInterfacesExclude-hint`}
>
<div
role="group"
aria-labelledby={`${id}-discoveryInterfacesExclude-label`}
aria-describedby={`${id}-discoveryInterfacesExclude-hint`}
>
<StringListEditor
value={values.discoveryInterfacesExclude}
disabled={disabled}
placeholder="en*"
inputAriaLabel={t(
'config_management.visual.serverExtras.discoveryInterfacesExclude.label'
)}
onChange={(discoveryInterfacesExclude) => onChange({ discoveryInterfacesExclude })}
/>
</div>
</FieldShell>
</FieldAnchor>
<FieldAnchor fieldId="discoveryAuthRequired">
<ToggleRow
title={t('config_management.visual.serverExtras.discoveryAuthRequired.label')}
description={t('config_management.visual.serverExtras.discoveryAuthRequired.hint')}
checked={values.discoveryAuthRequired}
disabled={disabled}
onChange={(discoveryAuthRequired) => onChange({ discoveryAuthRequired })}
/>
</FieldAnchor>
<FieldAnchor fieldId="discoveryAdvertiseManagement">
<ToggleRow
title={t('config_management.visual.serverExtras.discoveryAdvertiseManagement.label')}
description={t(
'config_management.visual.serverExtras.discoveryAdvertiseManagement.hint'
)}
checked={values.discoveryAdvertiseManagement}
disabled={disabled}
onChange={(discoveryAdvertiseManagement) => onChange({ discoveryAdvertiseManagement })}
/>
</FieldAnchor>
</FieldStack>
</Collapsible>
);
}
+10 -1
View File
@@ -71,7 +71,7 @@ export const COMMON_FIELD_IDS = [
*/
export const SECTION_VALIDATION_FIELDS: Record<VisualSectionId, readonly VisualConfigFieldPath[]> =
{
connectivity: ['port'],
connectivity: ['port', 'trustedProxies', 'discoveryServiceType'],
network: [
'requestRetry',
'maxRetryCredentials',
@@ -135,6 +135,15 @@ export const FIELD_VALUE_KEYS: Record<string, readonly string[]> = {
codexLiveMediaRelayICEServers: ['codexLiveMediaRelayICEServers'],
// ── connectivity ──────────────────────────────────────────────────────────
trustedProxies: ['trustedProxies'],
discoveryEnabled: ['discoveryEnabled'],
discoveryServiceName: ['discoveryServiceName'],
discoveryServiceType: ['discoveryServiceType'],
discoverySubtypes: ['discoverySubtypes'],
discoveryInterfacesInclude: ['discoveryInterfacesInclude'],
discoveryInterfacesExclude: ['discoveryInterfacesExclude'],
discoveryAuthRequired: ['discoveryAuthRequired'],
discoveryAdvertiseManagement: ['discoveryAdvertiseManagement'],
host: ['host'],
port: ['port'],
authDir: ['authDir'],
@@ -28,6 +28,24 @@ export function readCommercialModeFromYaml(yamlContent: string): boolean {
}
}
export function hasTrustedProxiesChange(beforeYaml: string, afterYaml: string): boolean {
const read = (yaml: string): string[] => {
try {
const parsed: unknown = parseYaml(yaml);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return [];
const server: unknown = (parsed as Record<string, unknown>).server;
if (!server || typeof server !== 'object' || Array.isArray(server)) return [];
const value: unknown = (server as Record<string, unknown>)['trusted-proxies'];
return Array.isArray(value)
? [...new Set(value.filter((entry): entry is string => typeof entry === 'string'))].sort()
: [];
} catch {
return [];
}
};
return JSON.stringify(read(beforeYaml)) !== JSON.stringify(read(afterYaml));
}
function normalizeYamlForVisualDiff(yamlContent: string): string {
try {
const doc = parseDocument(yamlContent);
@@ -199,6 +217,7 @@ export function useConfigDocument({
const previousCommercialMode = readCommercialModeFromYaml(latestServerYaml);
const nextCommercialMode = readCommercialModeFromYaml(mergedYaml);
const commercialModeChanged = previousCommercialMode !== nextCommercialMode;
const trustedProxiesChanged = hasTrustedProxiesChange(latestServerYaml, mergedYaml);
if (previewMode === 'visual' && !previewPlan) return;
writeAttempted = true;
@@ -243,6 +262,9 @@ export function useConfigDocument({
if (commercialModeChanged) {
showNotification(t('notification.commercial_mode_restart_required'), 'warning');
}
if (trustedProxiesChanged) {
showNotification(t('notification.trusted_proxies_restart_required'), 'warning');
}
} catch (err: unknown) {
if (!operation.isCurrent()) return;
const message =
@@ -267,6 +289,9 @@ export function useConfigDocument({
) {
showNotification(t('notification.commercial_mode_restart_required'), 'warning');
}
if (hasTrustedProxiesChange(previewServerYaml, latestYaml)) {
showNotification(t('notification.trusted_proxies_restart_required'), 'warning');
}
} catch (recoveryError) {
if (!operation.isCurrent()) return;
// Until readback succeeds, freeze editing against the stale baseline. Saving retries
+63
View File
@@ -222,6 +222,69 @@ export const CONFIG_FIELD_SEARCH_INDEX: ConfigFieldSearchEntry[] = [
},
// ── connectivity ──────────────────────────────────────────────────────────
{
fieldId: 'trustedProxies',
sectionId: 'connectivity',
labelKey: L('serverExtras.trustedProxies.label'),
hintKey: L('serverExtras.trustedProxies.hint'),
yamlKeys: ['server', 'trusted-proxies'],
},
{
fieldId: 'discoveryEnabled',
sectionId: 'connectivity',
labelKey: L('serverExtras.discoveryEnabled.label'),
hintKey: L('serverExtras.discoveryEnabled.hint'),
yamlKeys: ['server', 'discovery', 'enabled'],
},
{
fieldId: 'discoveryServiceName',
sectionId: 'connectivity',
labelKey: L('serverExtras.discoveryServiceName.label'),
hintKey: L('serverExtras.discoveryServiceName.hint'),
yamlKeys: ['server', 'discovery', 'service-name'],
},
{
fieldId: 'discoveryServiceType',
sectionId: 'connectivity',
labelKey: L('serverExtras.discoveryServiceType.label'),
hintKey: L('serverExtras.discoveryServiceType.hint'),
yamlKeys: ['server', 'discovery', 'service-type'],
},
{
fieldId: 'discoverySubtypes',
sectionId: 'connectivity',
labelKey: L('serverExtras.discoverySubtypes.label'),
hintKey: L('serverExtras.discoverySubtypes.hint'),
yamlKeys: ['server', 'discovery', 'subtypes'],
},
{
fieldId: 'discoveryInterfacesInclude',
sectionId: 'connectivity',
labelKey: L('serverExtras.discoveryInterfacesInclude.label'),
hintKey: L('serverExtras.discoveryInterfacesInclude.hint'),
yamlKeys: ['server', 'discovery', 'interfaces', 'include'],
},
{
fieldId: 'discoveryInterfacesExclude',
sectionId: 'connectivity',
labelKey: L('serverExtras.discoveryInterfacesExclude.label'),
hintKey: L('serverExtras.discoveryInterfacesExclude.hint'),
yamlKeys: ['server', 'discovery', 'interfaces', 'exclude'],
},
{
fieldId: 'discoveryAuthRequired',
sectionId: 'connectivity',
labelKey: L('serverExtras.discoveryAuthRequired.label'),
hintKey: L('serverExtras.discoveryAuthRequired.hint'),
yamlKeys: ['server', 'discovery', 'auth-required'],
},
{
fieldId: 'discoveryAdvertiseManagement',
sectionId: 'connectivity',
labelKey: L('serverExtras.discoveryAdvertiseManagement.label'),
hintKey: L('serverExtras.discoveryAdvertiseManagement.hint'),
yamlKeys: ['server', 'discovery', 'advertise-management'],
},
{
fieldId: 'host',
sectionId: 'connectivity',
+151
View File
@@ -0,0 +1,151 @@
import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
import { DEFAULT_VISUAL_VALUES } from '@/types/visualConfig';
import type { VisualConfigValues, VisualConfigValidationErrors } from '@/types/visualConfig';
import { assertConfigListsUnchanged } from '@/services/api/configPatch';
// Backend: config_v8.go, trusted_proxies.go and discovery/service.go.
export const SERVER_FIELDS = [
{ key: 'trustedProxies', path: ['server', 'trusted-proxies'], kind: 'list' },
{ key: 'discoveryEnabled', path: ['server', 'discovery', 'enabled'], kind: 'boolean' },
{ key: 'discoveryServiceName', path: ['server', 'discovery', 'service-name'], kind: 'string' },
{ key: 'discoveryServiceType', path: ['server', 'discovery', 'service-type'], kind: 'string' },
{ key: 'discoverySubtypes', path: ['server', 'discovery', 'subtypes'], kind: 'list' },
{
key: 'discoveryInterfacesInclude',
path: ['server', 'discovery', 'interfaces', 'include'],
kind: 'list',
},
{
key: 'discoveryInterfacesExclude',
path: ['server', 'discovery', 'interfaces', 'exclude'],
kind: 'list',
},
{ key: 'discoveryAuthRequired', path: ['server', 'discovery', 'auth-required'], kind: 'boolean' },
{
key: 'discoveryAdvertiseManagement',
path: ['server', 'discovery', 'advertise-management'],
kind: 'boolean',
},
] as const;
type Doc = ReturnType<typeof parseDocument>;
const cleanList = (values: string[]) => values.map((value) => value.trim()).filter(Boolean);
export function readVisualServer(doc: Doc) {
const values = {} as Pick<VisualConfigValues, (typeof SERVER_FIELDS)[number]['key']>;
for (const { key, path, kind } of SERVER_FIELDS) {
const raw = doc.getIn(path);
Object.assign(values, {
[key]:
kind === 'list'
? isSeq(raw)
? cleanList(
raw.items.flatMap((item) =>
isScalar(item) && typeof item.value === 'string' ? [item.value] : []
)
)
: []
: typeof raw === kind
? raw
: DEFAULT_VISUAL_VALUES[key],
});
}
return values;
}
function parents(doc: Doc, path: readonly string[]) {
for (let i = 1; i < path.length; i++) {
const parent = path.slice(0, i);
const old = doc.getIn(parent, true);
if (!isMap(old)) {
const replacement = doc.createNode({});
if (isScalar(old) || isSeq(old)) {
replacement.comment = old.comment;
replacement.commentBefore = old.commentBefore;
replacement.spaceBefore = old.spaceBefore;
}
doc.setIn(parent, replacement);
}
}
}
/** Lists are atomic, guarded against concurrent edits, and use the retained draft AST. */
export function writeVisualServer(
doc: Doc,
values: VisualConfigValues,
dirty: ReadonlySet<string>,
sourceYaml: string,
serverYaml: string,
guard: boolean
) {
const source = parseDocument(sourceYaml);
const latestYaml = doc.toString();
for (const { key, path, kind } of SERVER_FIELDS) {
if (!dirty.has(key)) continue;
const value = values[key];
if (kind === 'list' && Array.isArray(value)) {
const old = source.getIn(path, true);
const seq = isSeq(old) ? old : source.createNode([]);
// Match duplicate occurrences independently; never deduplicate or interpret patterns.
const available = [...seq.items];
seq.items = cleanList(value).map((text) => {
const index = available.findIndex((item) => isScalar(item) && item.value === text);
return index < 0 ? source.createNode(text) : available.splice(index, 1)[0];
});
parents(source, path);
source.setIn(path, seq);
if (guard) assertConfigListsUnchanged(serverYaml, source.toString(), latestYaml, [path]);
parents(doc, path);
doc.setIn(path, seq);
} else {
parents(doc, path);
const old = doc.getIn(path, true);
const desired = typeof value === 'string' ? value.trim() : value;
if (isScalar(old)) old.value = desired;
else doc.setIn(path, desired);
}
}
}
function validIPv4(raw: string): boolean {
return (
/^(?:0|[1-9]\d{0,2})(?:\.(?:0|[1-9]\d{0,2})){3}$/.test(raw) &&
raw.split('.').every((part) => Number(part) <= 255)
);
}
function validIPv6(raw: string): boolean {
if (!raw.includes(':') || !/^[\da-fA-F:.]+$/.test(raw)) return false;
if (raw.includes('.') && !validIPv4(raw.slice(raw.lastIndexOf(':') + 1))) return false;
// URL's bracketed-host parser implements IPv6 syntax; the checks above reject
// zones and its otherwise permissive embedded IPv4 spelling.
try {
return new URL(`http://[${raw}]/`).hostname.startsWith('[');
} catch {
return false;
}
}
export function validTrustedProxy(raw: string): boolean {
const parts = raw.trim().split('/');
if (parts.length > 2) return false;
const [ip, prefix] = parts;
const ipv6 = ip.includes(':');
if (!(ipv6 ? validIPv6(ip) : validIPv4(ip))) return false;
return prefix === undefined || (/^\d+$/.test(prefix) && Number(prefix) <= (ipv6 ? 128 : 32));
}
export function validateVisualServer(values: VisualConfigValues): VisualConfigValidationErrors {
const serviceType = values.discoveryServiceType.trim();
return {
trustedProxies: cleanList(values.trustedProxies).every(validTrustedProxy)
? undefined
: 'invalid_trusted_proxies',
discoveryServiceType:
!values.discoveryEnabled ||
!serviceType ||
/^_[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,13}[a-zA-Z0-9])?\._tcp$/.test(serviceType)
? undefined
: 'invalid_discovery_service_type',
};
}
+22
View File
@@ -27,6 +27,13 @@ import {
validateVisualAdditions,
} from '@/features/config/visualConfigAdditions';
import {
SERVER_FIELDS,
readVisualServer,
writeVisualServer,
validateVisualServer,
} from '@/features/config/visualConfigServer';
function asRecord(value: unknown): Record<string, unknown> | null {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
return value as Record<string, unknown>;
@@ -181,6 +188,7 @@ export function getVisualConfigValidationErrors(
): VisualConfigValidationErrors {
return {
...validateVisualAdditions(values),
...validateVisualServer(values),
port: getPortError(values.port),
errorLogsMaxFiles: getNonNegativeIntegerError(values.errorLogsMaxFiles),
logsMaxTotalSizeMb: getNonNegativeIntegerError(values.logsMaxTotalSizeMb),
@@ -1184,6 +1192,11 @@ function getNextDirtyFields(
}
};
SERVER_FIELDS.forEach(({ key }) => {
if (Object.prototype.hasOwnProperty.call(patch, key)) {
updateDirty(key, JSON.stringify(nextValues[key]) === JSON.stringify(baselineValues[key]));
}
});
ADDITION_FIELDS.forEach(({ key }) => updateScalarDirty(key));
if (Object.prototype.hasOwnProperty.call(patch, ICE_KEY)) {
updateDirty(
@@ -1423,6 +1436,7 @@ function parseVisualValuesFromYaml(yamlContent: string): VisualConfigValues {
const newValues: VisualConfigValues = {
...readVisualAdditions(document),
...readVisualServer(document),
host: typeof v8Server?.['host'] === 'string' ? v8Server?.['host'] : '',
port: String(v8Server?.['port'] ?? ''),
@@ -1627,6 +1641,14 @@ export function useVisualConfig() {
}
const values = visualValues;
writeVisualAdditions(doc, values, dirtyFields);
writeVisualServer(
doc,
values,
dirtyFields,
rebasedPayload?.yaml ?? baselineYaml,
rebasedPayload?.serverYaml ?? baselineYaml,
target === 'server'
);
if (dirtyFields.has(ICE_KEY)) {
writeICEServers(
doc,
+43
View File
@@ -1043,6 +1043,46 @@
"no_changes": "No changes detected"
},
"visual": {
"serverExtras": {
"trustedProxies": {
"label": "Trusted proxies",
"hint": "IP addresses or CIDRs; empty means forwarded headers are not trusted. Trusting all-address ranges risks client IP spoofing. Requires a backend restart."
},
"discoveryEnabled": {
"label": "Enable discovery",
"hint": "Broadcast via mDNS / DNS-SD on the LAN. Off by default; changes apply without a restart."
},
"discoveryServiceName": {
"label": "Service name prefix",
"hint": "The backend appends a short ID. Empty uses CPA-<ShortID>."
},
"discoveryServiceType": {
"label": "Service type",
"hint": "Empty uses _ai-gateway._tcp."
},
"discoverySubtypes": {
"label": "Protocol subtypes",
"hint": "Empty uses the five default protocols. Missing leading _ is added; invalid entries are ignored."
},
"discoveryInterfacesInclude": {
"label": "Include interfaces",
"hint": "Empty auto-selects physical LAN interfaces. Use names or prefix patterns ending in *."
},
"discoveryInterfacesExclude": {
"label": "Exclude interfaces",
"hint": "Use names or prefix patterns ending in *. Exclusions take priority over inclusions."
},
"discoveryAuthRequired": {
"label": "Advertise authentication requirement",
"hint": "On by default. Broadcast metadata only; does not change actual API authentication."
},
"discoveryAdvertiseManagement": {
"label": "Advertise management service",
"hint": "Off by default. Only announces the management service exists; does not change remote access permissions."
},
"discoveryTitle": "LAN discovery",
"discoveryHint": "Requires multicast-capable physical interfaces and a non-loopback listener. Docker usually needs host networking; reachability is not guaranteed."
},
"additions": {
"oauthTitle": "OAuth provider behavior",
"oauthHint": "These provider settings apply only to OAuth/file-backed credentials, not API-key credentials.",
@@ -1435,6 +1475,8 @@
"boolean_false": "false"
},
"validation": {
"invalid_trusted_proxies": "Each trusted proxy must be a valid IP address or CIDR.",
"invalid_discovery_service_type": "Use _name._tcp: name must be 1–15 letters, digits or hyphens, with no leading or trailing hyphen.",
"validation_blocked": "Fix validation errors before saving",
"invalid_duration": "Enter a valid duration, such as 30s or 3h",
"positive_duration": "Enter a duration greater than zero",
@@ -1702,6 +1744,7 @@
"clear_login_confirm": "Clear local login data and sign out now?"
},
"notification": {
"trusted_proxies_restart_required": "Trusted proxy settings saved. Restart the backend to apply them.",
"region_label": "Notifications",
"type_success": "Success",
"type_info": "Information",
+43
View File
@@ -1046,6 +1046,46 @@
"no_changes": "Изменений не обнаружено"
},
"visual": {
"serverExtras": {
"trustedProxies": {
"label": "Доверенные прокси",
"hint": "IP-адреса или CIDR; пустой список отключает доверие к заголовкам пересылки. Доверие ко всем адресам создаёт риск подмены IP клиента. Нужен перезапуск бэкенда."
},
"discoveryEnabled": {
"label": "Включить обнаружение",
"hint": "Объявлять сервис через mDNS / DNS-SD в локальной сети. По умолчанию выключено; изменения применяются без перезапуска."
},
"discoveryServiceName": {
"label": "Префикс имени сервиса",
"hint": "Бэкенд добавляет короткий ID. Если пусто, используется CPA-<ShortID>."
},
"discoveryServiceType": {
"label": "Тип сервиса",
"hint": "Если пусто, используется _ai-gateway._tcp."
},
"discoverySubtypes": {
"label": "Подтипы протоколов",
"hint": "Если пусто, используются пять стандартных протоколов. Начальный _ добавляется автоматически; неверные записи игнорируются."
},
"discoveryInterfacesInclude": {
"label": "Включить интерфейсы",
"hint": "Если пусто, физические интерфейсы локальной сети выбираются автоматически. Укажите имена или префиксы с * в конце."
},
"discoveryInterfacesExclude": {
"label": "Исключить интерфейсы",
"hint": "Укажите имена или префиксы с * в конце. Исключения имеют приоритет над включениями."
},
"discoveryAuthRequired": {
"label": "Объявлять требование аутентификации",
"hint": "По умолчанию включено. Только метаданные объявления; реальная аутентификация API не меняется."
},
"discoveryAdvertiseManagement": {
"label": "Объявлять сервис управления",
"hint": "По умолчанию выключено. Объявляет лишь наличие сервиса управления; права удалённого доступа не меняются."
},
"discoveryTitle": "Обнаружение в локальной сети",
"discoveryHint": "Нужны физические интерфейсы с поддержкой multicast и прослушивание не на loopback. Docker обычно требует сеть host; доступность не гарантируется."
},
"additions": {
"oauthTitle": "Поведение OAuth-провайдеров",
"oauthHint": "Эти настройки провайдеров применяются только к OAuth и учётным данным из файлов, но не к API-ключам.",
@@ -1438,6 +1478,8 @@
"boolean_false": "false"
},
"validation": {
"invalid_trusted_proxies": "Каждый доверенный прокси должен быть корректным IP-адресом или CIDR.",
"invalid_discovery_service_type": "Используйте _имя._tcp: имя — 1–15 латинских букв, цифр или дефисов, без дефиса в начале и конце.",
"validation_blocked": "Исправьте ошибки валидации перед сохранением",
"invalid_duration": "Введите корректную длительность, например 30s или 3h",
"positive_duration": "Введите длительность больше нуля",
@@ -1696,6 +1738,7 @@
"clear_login_confirm": "Очистить локальные данные входа и выйти?"
},
"notification": {
"trusted_proxies_restart_required": "Настройки доверенных прокси сохранены. Перезапустите бэкенд для их применения.",
"region_label": "Уведомления",
"type_success": "Успешно",
"type_info": "Информация",
+43
View File
@@ -1041,6 +1041,46 @@
"no_changes": "未检测到变更"
},
"visual": {
"serverExtras": {
"trustedProxies": {
"label": "可信代理",
"hint": "填写 IP 地址或 CIDR;留空不信任转发头。信任覆盖所有地址的网段有客户端 IP 伪造风险。重启后端后生效。"
},
"discoveryEnabled": {
"label": "启用服务发现",
"hint": "通过 mDNS / DNS-SD 在局域网广播。默认关闭,支持热更新。"
},
"discoveryServiceName": {
"label": "服务名称前缀",
"hint": "后端自动追加短 ID,留空使用 CPA-<ShortID>。"
},
"discoveryServiceType": {
"label": "服务类型",
"hint": "留空使用 _ai-gateway._tcp。"
},
"discoverySubtypes": {
"label": "协议子类型",
"hint": "留空使用默认的五种协议。缺少前导 _ 时自动补齐,非法项会被忽略。"
},
"discoveryInterfacesInclude": {
"label": "包含网卡",
"hint": "留空自动选择物理局域网网卡。支持网卡名称或以 * 结尾的前缀匹配。"
},
"discoveryInterfacesExclude": {
"label": "排除网卡",
"hint": "支持网卡名称或以 * 结尾的前缀匹配。排除规则优先于包含规则。"
},
"discoveryAuthRequired": {
"label": "广播鉴权要求",
"hint": "默认开启。仅广播元数据,不改变实际 API 鉴权。"
},
"discoveryAdvertiseManagement": {
"label": "广播管理服务",
"hint": "默认关闭。仅广播管理服务存在,不修改远程访问权限。"
},
"discoveryTitle": "局域网发现",
"discoveryHint": "需要支持组播的物理网卡及非回环监听地址。Docker 通常需使用 host 网络;不保证服务可达。"
},
"additions": {
"oauthTitle": "OAuth 提供商行为",
"oauthHint": "本组提供商设置仅适用于 OAuth/认证文件凭据,不影响 API Key 凭据。",
@@ -1433,6 +1473,8 @@
"boolean_false": "false"
},
"validation": {
"invalid_trusted_proxies": "每个可信代理必须是有效的 IP 地址或 CIDR。",
"invalid_discovery_service_type": "请使用 _名称._tcp 格式:名称为 1–15 位英文字母、数字或连字符,首尾不能是连字符。",
"validation_blocked": "请先修复表单校验错误再保存",
"invalid_duration": "请输入有效时长,例如 30s 或 3h",
"positive_duration": "请输入大于零的时长",
@@ -1700,6 +1742,7 @@
"clear_login_confirm": "确认清理本地登录信息并退出登录?"
},
"notification": {
"trusted_proxies_restart_required": "可信代理配置已保存,重启后端后生效。",
"region_label": "通知",
"type_success": "成功",
"type_info": "提示",
+43
View File
@@ -1067,6 +1067,46 @@
"no_changes": "未偵測到變更"
},
"visual": {
"serverExtras": {
"trustedProxies": {
"label": "可信任代理",
"hint": "填寫 IP 位址或 CIDR;留空不信任轉送標頭。信任涵蓋所有位址的網段有用戶端 IP 偽造風險。重新啟動後端後生效。"
},
"discoveryEnabled": {
"label": "啟用服務探索",
"hint": "透過 mDNS / DNS-SD 在區域網路廣播。預設關閉,支援熱更新。"
},
"discoveryServiceName": {
"label": "服務名稱前綴",
"hint": "後端自動附加短 ID,留空使用 CPA-<ShortID>。"
},
"discoveryServiceType": {
"label": "服務類型",
"hint": "留空使用 _ai-gateway._tcp。"
},
"discoverySubtypes": {
"label": "協定子類型",
"hint": "留空使用預設的五種協定。缺少開頭的 _ 時自動補齊,無效項目會被忽略。"
},
"discoveryInterfacesInclude": {
"label": "包含網卡",
"hint": "留空自動選擇實體區域網路網卡。支援網卡名稱或以 * 結尾的前綴比對。"
},
"discoveryInterfacesExclude": {
"label": "排除網卡",
"hint": "支援網卡名稱或以 * 結尾的前綴比對。排除規則優先於包含規則。"
},
"discoveryAuthRequired": {
"label": "廣播驗證要求",
"hint": "預設開啟。僅廣播中繼資料,不改變實際 API 身分驗證。"
},
"discoveryAdvertiseManagement": {
"label": "廣播管理服務",
"hint": "預設關閉。僅廣播管理服務存在,不修改遠端存取權限。"
},
"discoveryTitle": "區域網路探索",
"discoveryHint": "需要支援多播的實體網卡及非回環監聽位址。Docker 通常需使用 host 網路;不保證服務可連線。"
},
"additions": {
"oauthTitle": "OAuth 供應商行為",
"oauthHint": "本組供應商設定僅適用於 OAuth/驗證檔案憑證,不影響 API Key 憑證。",
@@ -1459,6 +1499,8 @@
"boolean_false": "false"
},
"validation": {
"invalid_trusted_proxies": "每個可信任代理必須是有效的 IP 位址或 CIDR。",
"invalid_discovery_service_type": "請使用 _名稱._tcp 格式:名稱為 1–15 位英文字母、數字或連字號,首尾不能是連字號。",
"validation_blocked": "請先修復表單驗證錯誤再儲存",
"invalid_duration": "請輸入有效的時間長度,例如 30s 或 3h",
"positive_duration": "請輸入大於零的時間長度",
@@ -1726,6 +1768,7 @@
"clear_login_confirm": "確認清理本地登入資訊並登出?"
},
"notification": {
"trusted_proxies_restart_required": "可信任代理設定已儲存,重新啟動後端後生效。",
"region_label": "通知",
"type_success": "成功",
"type_info": "提示",
+24
View File
@@ -14,6 +14,8 @@ export type CodexLiveICEServerDraft = {
};
export type VisualConfigFieldPath =
| 'trustedProxies'
| 'discoveryServiceType'
| 'transientErrorCooldownSeconds'
| 'videoResultAuthCacheTTL'
| 'claudeHeaderTimezone'
@@ -38,6 +40,8 @@ export type VisualConfigFieldPath =
| 'streaming.nonstreamKeepaliveInterval';
export type VisualConfigValidationErrorCode =
| 'invalid_trusted_proxies'
| 'invalid_discovery_service_type'
| 'invalid_duration'
| 'positive_duration'
| 'invalid_timezone'
@@ -113,6 +117,16 @@ export type PluginStoreAuthRule = {
/** UI draft keys; YAML persistence uses the v8 tree, not these flattened names. */
export type VisualConfigValues = {
trustedProxies: string[];
discoveryEnabled: boolean;
discoveryServiceName: string;
discoveryServiceType: string;
discoverySubtypes: string[];
discoveryInterfacesInclude: string[];
discoveryInterfacesExclude: string[];
discoveryAuthRequired: boolean;
discoveryAdvertiseManagement: boolean;
routingSessionAffinitySubagents: boolean;
saveCooldownStatus: boolean;
transientErrorCooldownSeconds: string;
@@ -207,6 +221,16 @@ export const makeClientId = () => {
};
export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
trustedProxies: [],
discoveryEnabled: false,
discoveryServiceName: '',
discoveryServiceType: '',
discoverySubtypes: [],
discoveryInterfacesInclude: [],
discoveryInterfacesExclude: [],
discoveryAuthRequired: true,
discoveryAdvertiseManagement: false,
routingSessionAffinitySubagents: true,
saveCooldownStatus: false,
transientErrorCooldownSeconds: '',
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { hasTrustedProxiesChange } from '@/features/config/hooks/useConfigDocument';
import { findConfigFieldById } from '@/features/config/searchIndex';
const trusted = 'server: {trusted-proxies: [127.0.0.1, "2001:db8::/32"]}';
describe('trusted proxy restart notification', () => {
test('detects adding, removing, and replacing trusted networks', () => {
expect(hasTrustedProxiesChange('{}', trusted)).toBe(true);
expect(hasTrustedProxiesChange(trusted, '{}')).toBe(true);
expect(hasTrustedProxiesChange(trusted, 'server: {trusted-proxies: []}')).toBe(true);
expect(hasTrustedProxiesChange(trusted, trusted.replace('127.0.0.1', '10.0.0.0/8'))).toBe(true);
});
test('ignores presentation changes, order, duplicates, and discovery-only edits', () => {
expect(hasTrustedProxiesChange('{}', 'server: {trusted-proxies: []}')).toBe(false);
expect(hasTrustedProxiesChange('server: null', 'server: {trusted-proxies: null}')).toBe(false);
expect(hasTrustedProxiesChange(trusted, '# retained\n' + trusted)).toBe(false);
expect(
hasTrustedProxiesChange(
trusted,
'server: {trusted-proxies: ["2001:db8::/32", 127.0.0.1, 127.0.0.1]}'
)
).toBe(false);
expect(
hasTrustedProxiesChange(
trusted,
trusted.replace('server: {', 'server: {discovery: {enabled: true}, ')
)
).toBe(false);
expect(hasTrustedProxiesChange('{}', 'server: {discovery: {enabled: true}}')).toBe(false);
});
test('supports YAML aliases and tolerates invalid documents during recovery', () => {
expect(
hasTrustedProxiesChange(
'{}',
'access: {api-keys: &fixture [127.0.0.1]}\nserver: {trusted-proxies: *fixture}'
)
).toBe(true);
expect(hasTrustedProxiesChange('server: [', '{}')).toBe(false);
});
test('warns on both successful save and readback after a partial visual write', () => {
const source = readFileSync('src/features/config/hooks/useConfigDocument.ts', 'utf8');
expect(source).toContain('hasTrustedProxiesChange(latestServerYaml, mergedYaml)');
expect(source).toContain('hasTrustedProxiesChange(previewServerYaml, latestYaml)');
expect(source.match(/notification\.trusted_proxies_restart_required/g)?.length).toBe(2);
});
});
describe('server configuration localization', () => {
for (const locale of ['en', 'zh-CN', 'zh-TW', 'ru']) {
test(`${locale} has labels, hints, validation and restart text`, async () => {
const messages: unknown = await Bun.file(`src/i18n/locales/${locale}.json`).json();
const resolve = (key: string) =>
key.split('.').reduce<unknown>((node, part) => {
if (node && typeof node === 'object') return (node as Record<string, unknown>)[part];
return undefined;
}, messages);
for (const id of [
'trustedProxies',
'discoveryEnabled',
'discoveryServiceName',
'discoveryServiceType',
'discoverySubtypes',
'discoveryInterfacesInclude',
'discoveryInterfacesExclude',
'discoveryAuthRequired',
'discoveryAdvertiseManagement',
]) {
const entry = findConfigFieldById(id);
expect(entry?.sectionId).toBe('connectivity');
expect(entry?.labelKey).toBe(`config_management.visual.serverExtras.${id}.label`);
expect(entry?.hintKey).toBe(`config_management.visual.serverExtras.${id}.hint`);
expect(resolve(entry!.labelKey)).toBeString();
expect(resolve(entry!.hintKey!)).toBeString();
}
for (const key of [
'notification.trusted_proxies_restart_required',
'config_management.visual.serverExtras.discoveryTitle',
'config_management.visual.serverExtras.discoveryHint',
'config_management.visual.validation.invalid_trusted_proxies',
'config_management.visual.validation.invalid_discovery_service_type',
])
expect(resolve(key)).toBeString();
});
}
});
+147
View File
@@ -0,0 +1,147 @@
import { describe, expect, test } from 'bun:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nextProvider } from 'react-i18next';
import i18n from '@/i18n';
import { DEFAULT_VISUAL_VALUES } from '@/types/visualConfig';
import { SectionConnectivity } from '@/features/config/components/sections/SectionConnectivity';
import { SectionDiscovery } from '@/features/config/components/sections/SectionDiscovery';
import {
CONFIG_SECTION_IDS,
FIELD_VALUE_KEYS,
SECTION_VALIDATION_FIELDS,
} from '@/features/config/constants';
import { CONFIG_FIELD_SEARCH_INDEX, searchConfigFields } from '@/features/config/searchIndex';
const translations = i18n.cloneInstance({ lng: 'en' });
const props = { values: DEFAULT_VISUAL_VALUES, disabled: false, onChange: () => {} };
const render = (element: ReturnType<typeof createElement>) =>
renderToStaticMarkup(createElement(I18nextProvider, { i18n: translations }, element));
const escapeText = (value: string) =>
renderToStaticMarkup(createElement('span', null, value)).slice(6, -7);
const extras = CONFIG_FIELD_SEARCH_INDEX.filter((entry) =>
entry.labelKey.includes('.serverExtras.')
);
const populated = {
...DEFAULT_VISUAL_VALUES,
trustedProxies: ['127.0.0.1', '192.168.0.0/24'],
discoverySubtypes: ['_responses'],
discoveryInterfacesInclude: ['en*'],
discoveryInterfacesExclude: ['docker*'],
};
describe('server configuration UI', () => {
test('keeps nine searchable anchors in connectivity without adding a tab', () => {
expect(CONFIG_SECTION_IDS).toHaveLength(7);
expect(extras).toHaveLength(9);
const markup = render(createElement(SectionConnectivity, props));
for (const entry of extras) {
expect(entry.sectionId).toBe('connectivity');
expect(FIELD_VALUE_KEYS[entry.fieldId]).toEqual([entry.fieldId]);
expect(markup.split(`id="cfg-field-${entry.fieldId}"`)).toHaveLength(2);
expect(markup).toContain(escapeText(translations.t(entry.labelKey)));
expect(markup).toContain(escapeText(translations.t(entry.hintKey!)));
const path = entry.yamlKeys!.join('.');
expect(path).toMatch(/^server\.(trusted-proxies|discovery\.)/);
expect(
searchConfigFields(path, (key) => translations.t(key)).map((e) => e.fieldId)
).toContain(entry.fieldId);
}
expect(SECTION_VALIDATION_FIELDS.connectivity).toEqual([
'port',
'trustedProxies',
'discoveryServiceType',
]);
});
test('discovery stays mounted in its own closed details even when disabled', () => {
const markup = render(createElement(SectionDiscovery, props));
expect(markup.match(/<details\b/g)).toHaveLength(1);
expect(markup.match(/<details\b[^>]*>/)?.[0]).not.toContain('open=');
expect(markup).toContain(
escapeText(translations.t('config_management.visual.serverExtras.discoveryTitle'))
);
for (const entry of extras.filter((e) => e.fieldId !== 'trustedProxies')) {
expect(markup).toContain(`id="cfg-field-${entry.fieldId}"`);
}
expect(DEFAULT_VISUAL_VALUES.discoveryEnabled).toBe(false);
expect(DEFAULT_VISUAL_VALUES.discoveryAuthRequired).toBe(true);
expect(DEFAULT_VISUAL_VALUES.discoveryAdvertiseManagement).toBe(false);
const auth = markup
.split('id="cfg-field-discoveryAuthRequired"')[1]
.split('id="cfg-field-discoveryAdvertiseManagement"')[0];
expect(auth).toContain('checked=""');
for (const input of markup.match(/<input\b[^>]*type="text"[^>]*>/g) ?? []) {
expect(input).toContain('value=""');
}
});
test('gives inputs and list rows accessible names and associates list hints', () => {
const markup = render(createElement(SectionConnectivity, { ...props, values: populated }));
for (const field of [
'trustedProxies',
'discoverySubtypes',
'discoveryInterfacesInclude',
'discoveryInterfacesExclude',
]) {
const label = escapeText(
translations.t(`config_management.visual.serverExtras.${field}.label`)
);
expect(markup).toContain(`aria-label="${label}"`);
const group = (markup.match(/<div\b[^>]*role="group"[^>]*>/g) ?? []).find((tag) =>
tag.includes(`${field}-label`)
);
expect(group).toBeTruthy();
for (const attribute of ['aria-labelledby', 'aria-describedby']) {
const id = group!.match(new RegExp(`${attribute}="([^"]+)"`))?.[1];
expect(id).toBeTruthy();
expect(markup).toContain(`id="${id}"`);
}
}
const discovery = render(createElement(SectionDiscovery, props));
const inputs = discovery.match(/<input\b[^>]*type="text"[^>]*>/g) ?? [];
expect(inputs).toHaveLength(2);
for (const input of inputs) {
const id = input.match(/\bid="([^"]+)"/)?.[1];
expect(discovery).toContain(`for="${id}"`);
const hint = input.match(/aria-describedby="([^"]+)"/)?.[1];
expect(discovery).toContain(`id="${hint}"`);
}
});
test('disables every input and list action in read-only mode', () => {
const markup = render(
createElement(SectionConnectivity, { ...props, values: populated, disabled: true })
);
const controls = markup.match(/<(?:input|textarea|button)\b[^>]*>/g) ?? [];
expect(controls.length).toBeGreaterThan(10);
for (const control of controls) expect(control).toContain('disabled=""');
});
test('renders and associates both validation errors', () => {
const markup = render(
createElement(SectionConnectivity, {
...props,
values: populated,
validationErrors: {
trustedProxies: 'invalid_trusted_proxies',
discoveryServiceType: 'invalid_discovery_service_type',
},
})
);
for (const code of ['invalid_trusted_proxies', 'invalid_discovery_service_type']) {
expect(markup).toContain(
escapeText(translations.t(`config_management.visual.validation.${code}`))
);
}
const group = (markup.match(/<div\b[^>]*role="group"[^>]*>/g) ?? []).find((tag) =>
tag.includes('trustedProxies-label')
)!;
expect(group).toContain('aria-invalid="true"');
const ids = group.match(/aria-describedby="([^"]+)"/)![1].split(' ');
expect(ids).toHaveLength(2);
for (const id of ids) expect(markup).toContain(`id="${id}"`);
const typeInput = markup.match(/<input\b[^>]*placeholder="_ai-gateway\._tcp"[^>]*>/)?.[0];
expect(typeInput).toContain('aria-invalid="true"');
});
});
+242
View File
@@ -0,0 +1,242 @@
import { describe, expect, test } from 'bun:test';
import { createElement, useState } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { parseDocument } from 'yaml';
import { useVisualConfig } from '../src/hooks/useVisualConfig';
import { DEFAULT_VISUAL_VALUES } from '../src/types/visualConfig';
import {
SERVER_FIELDS,
validateVisualServer,
validTrustedProxy,
} from '../src/features/config/visualConfigServer';
import { ConfigDraftConflictError } from '../src/services/api/configPatch';
import { runVisualConfig } from './helpers/visualConfig';
const yaml = `# root
server:
trusted-proxies: [127.0.0.1] # trusted
discovery:
enabled: true
service-type: _ai-gateway._tcp # type
subtypes: [responses, responses] # subtype
interfaces:
include: [en0]
exclude: [utun*]
future: retained # interface
future: retained # discovery
future: retained # server
api-keys: {codex: [{future: untouched}]}
`;
describe('server visual codec', () => {
for (const { key, path, kind } of SERVER_FIELDS) {
test(`${key}: exact read/write path, explicit values, source roundtrip`, () => {
const doc = parseDocument(yaml);
const before = kind === 'list' ? ['one', 'one'] : kind === 'boolean' ? true : 'original';
doc.setIn(path, before);
const source = doc.toString();
const loaded = runVisualConfig(source);
expect(loaded.visualValues[key]).toEqual(before);
const desired =
kind === 'list' ? [' two ', '', 'two'] : kind === 'boolean' ? false : 'changed';
const config = runVisualConfig(source, [{ [key]: desired }]);
expect([...config.visualDirtyFields]).toEqual([key]);
const output = config.applyVisualChangesToYaml(source);
expect(parseDocument(output).getIn(path, true)?.toJSON()).toEqual(
kind === 'list' ? ['two', 'two'] : desired
);
expect(runVisualConfig(output).visualValues[key]).toEqual(
kind === 'list' ? ['two', 'two'] : desired
);
expect(output).toContain('# root');
expect(output).toContain('# interface');
expect(parseDocument(output).toJS()['api-keys']).toEqual(doc.toJS()['api-keys']);
if (kind === 'list') {
const clear = runVisualConfig(source, [{ [key]: [] }]).applyVisualChangesToYaml(source);
expect(parseDocument(clear).getIn(path, true)?.toJSON()).toEqual([]);
expect(clear).toContain('[]');
expect(runVisualConfig(source, [{ [key]: [...(before as string[])] }]).visualDirty).toBe(
false
);
}
});
}
test('defaults and untouched values are not materialized or normalized', () => {
for (const source of ['{}', 'server: null', 'server: {discovery: null}']) {
const v = runVisualConfig(source);
for (const { key } of SERVER_FIELDS)
expect(v.visualValues[key]).toEqual(DEFAULT_VISUAL_VALUES[key]);
expect(parseDocument(v.applyVisualChangesToYaml(source)).toJS()).toEqual(
parseDocument(source).toJS()
);
}
const source = yaml.replace(
'subtypes: [responses, responses]',
'subtypes: [" odd._sub ", "", 7, future]'
);
const v = runVisualConfig(source, [{ discoveryAuthRequired: false }]);
const output = v.applyVisualChangesToYaml(source);
expect(output).toContain('auth-required: false');
expect(output).toContain('" odd._sub "');
expect(output).toContain('# type');
const original = parseDocument(source).toJS();
original.server.discovery['auth-required'] = false;
expect(parseDocument(output).toJS()).toEqual(original);
expect(validateVisualServer(DEFAULT_VISUAL_VALUES)).toEqual({
trustedProxies: undefined,
discoveryServiceType: undefined,
});
});
test('all null parent depths are safe and false overrides default true', () => {
for (const source of [
'server: null',
'server: {discovery: null}',
'server: {discovery: {interfaces: null}}',
]) {
const v = runVisualConfig(source, [
{ discoveryInterfacesInclude: ['en*'], discoveryAuthRequired: false },
]);
const output = parseDocument(v.applyVisualChangesToYaml(source));
expect(output.getIn(['server', 'discovery', 'auth-required'])).toBe(false);
expect(
output.getIn(['server', 'discovery', 'interfaces', 'include'], true)?.toJSON()
).toEqual(['en*']);
}
});
for (const { key, path, kind } of SERVER_FIELDS) {
if (kind !== 'list') continue;
test(`${key}: atomic concurrency guard and unrelated concurrent preservation`, () => {
const config = runVisualConfig(yaml, [{ [key]: ['changed'] }]);
const concurrent = parseDocument(yaml);
concurrent.setIn(path, ['remote']);
expect(() => config.applyVisualChangesToYaml(concurrent.toString())).toThrow(
ConfigDraftConflictError
);
const output = config.applyVisualChangesToYaml(
yaml.replace('future: untouched', 'future: concurrent')
);
expect(output).toContain('future: concurrent');
expect(() => config.applyVisualChangesToYaml(output)).not.toThrow();
expect(() => config.applyVisualChangesToYaml(concurrent.toString(), 'draft')).not.toThrow();
});
}
test('rebase retains list comments, detects conflicts and load resets session state', () => {
let done = false;
function Harness() {
const v = useVisualConfig();
const [phase, next] = useState(0);
if (phase === 0) v.loadVisualValuesFromYaml(yaml);
if (phase === 1)
v.setVisualValues({ discoverySubtypes: ['custom', 'responses'], trustedProxies: [] });
if (phase === 2) v.rebaseVisualValuesFromYaml(yaml, v.applyVisualChangesToYaml(yaml));
if (phase === 3) {
const output = v.applyVisualChangesToYaml(yaml);
expect(output).toContain('# subtype');
expect(parseDocument(output).getIn(['server', 'trusted-proxies'], true)?.toJSON()).toEqual(
[]
);
expect(() =>
v.applyVisualChangesToYaml(yaml.replace('[responses, responses]', '[remote]'))
).toThrow(ConfigDraftConflictError);
v.setVisualValues({ discoverySubtypes: ['custom', 'responses', 'added'] });
}
if (phase === 4) {
expect(v.applyVisualChangesToYaml(yaml)).toContain('added');
v.loadVisualValuesFromYaml('server: {discovery: {service-name: new-session}}');
}
if (phase === 5) {
expect(v.visualDirty).toBe(false);
expect(v.visualValues.discoverySubtypes).toEqual([]);
expect(v.visualValues.discoveryServiceName).toBe('new-session');
expect(v.applyVisualChangesToYaml('{}')).not.toContain('custom');
done = true;
} else next(phase + 1);
return null;
}
renderToStaticMarkup(createElement(Harness));
expect(done).toBe(true);
});
});
describe('backend server validation', () => {
test('strict IPv4/IPv6 and CIDR boundaries; trims and removes blank lines', () => {
for (const value of [
'0.0.0.0/0',
'255.255.255.255/32',
'127.0.0.1',
'::/0',
'::1/128',
'2001:db8::/64',
'::ffff:192.0.2.1/128',
'1:2:3:4:5:6:7:8',
' 192.0.2.0/24 ',
]) {
expect(validTrustedProxy(value)).toBe(true);
}
for (const value of [
'localhost',
'https://127.0.0.1',
'127.1',
'012.0.0.1',
'256.0.0.1',
'1.2.3.4/33',
'::/129',
'1.2.3.4/-1',
'::/+1',
'::/1.5',
'::/1e2',
'::/',
'::/1/2',
'fe80::1%en0',
'1.2. 3.4',
'::ffff:192.000.2.1',
'1:2:3:4:5:6:7',
'1::2::3',
'[::1]',
':: /64',
]) {
expect(validTrustedProxy(value)).toBe(false);
expect(
validateVisualServer({ ...DEFAULT_VISUAL_VALUES, trustedProxies: [value] }).trustedProxies
).toBe('invalid_trusted_proxies');
}
expect(
validateVisualServer({ ...DEFAULT_VISUAL_VALUES, trustedProxies: [' ', '\n', ' ::1 '] })
.trustedProxies
).toBeUndefined();
});
test('enabled service type follows backend rules, including numeric names', () => {
for (const value of ['', ' ', '_a._tcp', '_123._tcp', '_a-b._tcp', '_123456789012345._tcp']) {
expect(
validateVisualServer({
...DEFAULT_VISUAL_VALUES,
discoveryEnabled: true,
discoveryServiceType: value,
}).discoveryServiceType
).toBeUndefined();
}
for (const value of [
'_1234567890123456._tcp',
'_-a._tcp',
'_a-._tcp',
'_a_b._tcp',
'a._tcp',
'_a._udp',
'_a._TCP',
'_é._tcp',
'_._tcp',
]) {
expect(
validateVisualServer({
...DEFAULT_VISUAL_VALUES,
discoveryEnabled: true,
discoveryServiceType: value,
}).discoveryServiceType
).toBe('invalid_discovery_service_type');
expect(
validateVisualServer({ ...DEFAULT_VISUAL_VALUES, discoveryServiceType: value })
.discoveryServiceType
).toBeUndefined();
}
});
});