mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-10-16 14:57:05 +08:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Информация",
|
||||
|
||||
@@ -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": "提示",
|
||||
|
||||
@@ -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": "提示",
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user