From bb0e91830ef68fe741745b4af6f1ba86b3bea296 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sat, 27 Jun 2026 14:23:08 +0330 Subject: [PATCH 1/8] feat(dashboard): add xray api services allowlist helpers + tests --- dashboard/src/lib/xray-api-services.test.ts | 64 ++++++++++++++ dashboard/src/lib/xray-api-services.ts | 94 +++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 dashboard/src/lib/xray-api-services.test.ts create mode 100644 dashboard/src/lib/xray-api-services.ts diff --git a/dashboard/src/lib/xray-api-services.test.ts b/dashboard/src/lib/xray-api-services.test.ts new file mode 100644 index 000000000..590ff20da --- /dev/null +++ b/dashboard/src/lib/xray-api-services.test.ts @@ -0,0 +1,64 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { findUnknownApiServices, getSelectedOptional, setOptionalService } from './xray-api-services.ts' + +test('getSelectedOptional: no api yields empty', () => { + assert.deepEqual(getSelectedOptional({}), []) +}) + +test('getSelectedOptional: reads optional services case-insensitively', () => { + assert.deepEqual(getSelectedOptional({ api: { services: ['routingservice'] } }), ['RoutingService']) +}) + +test('getSelectedOptional: ignores required and unknown services', () => { + assert.deepEqual(getSelectedOptional({ api: { services: ['HandlerService', 'Foo'] } }), []) +}) + +test('getSelectedOptional: non-object input is safe', () => { + assert.deepEqual(getSelectedOptional(null), []) +}) + +test('findUnknownApiServices: flags unrecognized names, preserving order', () => { + assert.deepEqual( + findUnknownApiServices({ api: { services: ['RoutingService', 'Foo', 'RoutigService'] } }), + ['Foo', 'RoutigService'], + ) +}) + +test('findUnknownApiServices: known names (any case) are not flagged', () => { + assert.deepEqual(findUnknownApiServices({ api: { services: ['routingservice', 'StatsService'] } }), []) +}) + +test('setOptionalService: enabling creates api.services', () => { + assert.deepEqual(setOptionalService({}, 'RoutingService', true), { api: { services: ['RoutingService'] } }) +}) + +test('setOptionalService: enabling dedupes case-insensitively to canonical', () => { + assert.deepEqual( + setOptionalService({ api: { services: ['routingservice'] } }, 'RoutingService', true), + { api: { services: ['RoutingService'] } }, + ) +}) + +test('setOptionalService: disabling removes the service and drops an empty api', () => { + assert.deepEqual(setOptionalService({ api: { services: ['RoutingService'] } }, 'RoutingService', false), {}) +}) + +test('setOptionalService: disabling preserves other api keys', () => { + assert.deepEqual( + setOptionalService({ api: { services: ['RoutingService'], tag: 'API' } }, 'RoutingService', false), + { api: { tag: 'API' } }, + ) +}) + +test('setOptionalService: preserves other config keys and untouched (unknown) services', () => { + assert.deepEqual( + setOptionalService({ inbounds: [], api: { services: ['Foo'] } }, 'RoutingService', true), + { inbounds: [], api: { services: ['Foo', 'RoutingService'] } }, + ) +}) + +test('setOptionalService: unknown service name is a no-op', () => { + assert.deepEqual(setOptionalService({ api: { services: [] } }, 'Nope', true), { api: { services: [] } }) +}) diff --git a/dashboard/src/lib/xray-api-services.ts b/dashboard/src/lib/xray-api-services.ts new file mode 100644 index 000000000..c95fce862 --- /dev/null +++ b/dashboard/src/lib/xray-api-services.ts @@ -0,0 +1,94 @@ +// Always-injected by the node (backend/xray/config.go: requiredAPIServices). +// Shown locked in the UI; never written to the config by the panel. +export const REQUIRED_API_SERVICES = ['HandlerService', 'LoggerService', 'StatsService'] as const + +// Optional services an admin may opt into (node allowlist minus the required ones). +export const OPTIONAL_API_SERVICES = ['RoutingService', 'ObservatoryService', 'ReflectionService'] as const + +// Lowercase -> canonical name. Mirrors canonicalAPIServices in the node's +// backend/xray/config.go. Ceiling: there is no automatic drift detection — this +// map must be synced by hand when xray-core changes its API services. The unit +// test pins the expected names so a stale edit fails loudly. +export const CANONICAL_API_SERVICES: Record = { + reflectionservice: 'ReflectionService', + handlerservice: 'HandlerService', + loggerservice: 'LoggerService', + statsservice: 'StatsService', + observatoryservice: 'ObservatoryService', + routingservice: 'RoutingService', +} + +function readApiServices(config: unknown): string[] { + if (typeof config !== 'object' || config === null) return [] + const api = (config as Record).api + if (typeof api !== 'object' || api === null) return [] + const services = (api as Record).services + if (!Array.isArray(services)) return [] + return services.filter((s): s is string => typeof s === 'string') +} + +// Optional services currently present in the config, canonicalized and deduped. +export function getSelectedOptional(config: unknown): string[] { + const optional = new Set(OPTIONAL_API_SERVICES.map(s => s.toLowerCase())) + const selected: string[] = [] + const seen = new Set() + for (const raw of readApiServices(config)) { + const key = raw.trim().toLowerCase() + if (optional.has(key) && !seen.has(key)) { + seen.add(key) + selected.push(CANONICAL_API_SERVICES[key]) + } + } + return selected +} + +// Service names present in the config that are not in the node allowlist. +export function findUnknownApiServices(config: unknown): string[] { + const unknown: string[] = [] + const seen = new Set() + for (const raw of readApiServices(config)) { + const key = raw.trim().toLowerCase() + if (key === '' || seen.has(key)) continue + seen.add(key) + if (!CANONICAL_API_SERVICES[key]) unknown.push(raw) + } + return unknown +} + +// Return a new config with `service` added or removed from api.services. +// Only `api.services` is touched; other api/config keys are preserved. An empty +// services array is removed, and an api object with no remaining keys is dropped. +export function setOptionalService( + config: Record, + service: string, + enabled: boolean, +): Record { + const key = service.trim().toLowerCase() + const canonical = CANONICAL_API_SERVICES[key] + if (!canonical) return config + + const next: Record = { ...config } + const api: Record = + typeof next.api === 'object' && next.api !== null ? { ...(next.api as Record) } : {} + + const current = Array.isArray(api.services) + ? (api.services as unknown[]).filter((s): s is string => typeof s === 'string') + : [] + + const filtered = current.filter(s => s.trim().toLowerCase() !== key) + if (enabled) filtered.push(canonical) + + if (filtered.length > 0) { + api.services = filtered + next.api = api + return next + } + + delete api.services + if (Object.keys(api).length === 0) { + delete next.api + } else { + next.api = api + } + return next +} From 06324d7d040970c703b7093d6e626a9af483427c Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sat, 27 Jun 2026 14:36:11 +0330 Subject: [PATCH 2/8] test(dashboard): pin xray api-services allowlist + add test script --- dashboard/package.json | 3 ++- dashboard/src/lib/xray-api-services.test.ts | 29 ++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/dashboard/package.json b/dashboard/package.json index ffa37da85..a18b1f27f 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -8,7 +8,8 @@ "preview": "vite preview", "gen:api": "orval", "wait-port": "wait-port http://localhost:$UVICORN_PORT/openapi.json", - "wait-port-gen-api": "bun run wait-port && bun run gen:api" + "wait-port-gen-api": "bun run wait-port && bun run gen:api", + "test": "node --test 'src/**/*.test.ts'" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/dashboard/src/lib/xray-api-services.test.ts b/dashboard/src/lib/xray-api-services.test.ts index 590ff20da..719fa9ba6 100644 --- a/dashboard/src/lib/xray-api-services.test.ts +++ b/dashboard/src/lib/xray-api-services.test.ts @@ -1,7 +1,14 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { findUnknownApiServices, getSelectedOptional, setOptionalService } from './xray-api-services.ts' +import { + CANONICAL_API_SERVICES, + findUnknownApiServices, + getSelectedOptional, + OPTIONAL_API_SERVICES, + REQUIRED_API_SERVICES, + setOptionalService, +} from './xray-api-services.ts' test('getSelectedOptional: no api yields empty', () => { assert.deepEqual(getSelectedOptional({}), []) @@ -62,3 +69,23 @@ test('setOptionalService: preserves other config keys and untouched (unknown) se test('setOptionalService: unknown service name is a no-op', () => { assert.deepEqual(setOptionalService({ api: { services: [] } }, 'Nope', true), { api: { services: [] } }) }) + +test('allowlist constants are exactly the expected canonical names (drift guard)', () => { + assert.deepEqual([...REQUIRED_API_SERVICES], ['HandlerService', 'LoggerService', 'StatsService']) + assert.deepEqual([...OPTIONAL_API_SERVICES], ['RoutingService', 'ObservatoryService', 'ReflectionService']) +}) + +test('CANONICAL_API_SERVICES maps exactly REQUIRED ∪ OPTIONAL (both directions)', () => { + const all = [...REQUIRED_API_SERVICES, ...OPTIONAL_API_SERVICES] + for (const name of all) { + assert.equal(CANONICAL_API_SERVICES[name.toLowerCase()], name) + } + assert.deepEqual( + Object.keys(CANONICAL_API_SERVICES).sort(), + all.map(n => n.toLowerCase()).sort(), + ) +}) + +test('getSelectedOptional: trims and dedupes whitespace/case variants', () => { + assert.deepEqual(getSelectedOptional({ api: { services: [' routingservice ', 'RoutingService'] } }), ['RoutingService']) +}) From 7e1a36fdd4ad43658a4ee76d6a4f691d65f6cfdd Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sat, 27 Jun 2026 14:42:56 +0330 Subject: [PATCH 3/8] feat(dashboard): select optional xray API services in core config editor --- .../nodes/dialogs/core-config-modal.tsx | 81 ++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/dashboard/src/features/nodes/dialogs/core-config-modal.tsx b/dashboard/src/features/nodes/dialogs/core-config-modal.tsx index 2c8660749..ff136b34b 100644 --- a/dashboard/src/features/nodes/dialogs/core-config-modal.tsx +++ b/dashboard/src/features/nodes/dialogs/core-config-modal.tsx @@ -21,6 +21,7 @@ import { type VlessBuilderOptions, } from '@/lib/xray-generation' import { cn } from '@/lib/utils' +import { findUnknownApiServices, getSelectedOptional, OPTIONAL_API_SERVICES, REQUIRED_API_SERVICES, setOptionalService } from '@/lib/xray-api-services' import { useCreateCoreConfig, useModifyCoreConfig } from '@/service/api' import { isEmptyObject } from '@/utils/isEmptyObject.ts' import { generateMldsa65 } from '@/utils/mldsa65' @@ -30,7 +31,7 @@ import { encodeURLSafe } from '@stablelib/base64' import { generateKeyPair } from '@stablelib/x25519' import { debounce } from 'es-toolkit' import { Sparkles, Pencil, Cpu } from 'lucide-react' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import type { FieldErrors, UseFormReturn } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -147,6 +148,36 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit [validateJsonContent], ) + // Optional Xray API services derived from the config JSON (the JSON is the source of truth). + const watchedConfig = form.watch('config') + const { selectedApiServices, unknownApiServices } = useMemo(() => { + let parsed: unknown = null + try { + parsed = JSON.parse(watchedConfig || '{}') + } catch { + parsed = null + } + return { + selectedApiServices: getSelectedOptional(parsed), + unknownApiServices: findUnknownApiServices(parsed), + } + }, [watchedConfig]) + + const handleToggleApiService = useCallback( + (service: string, enabled: boolean) => { + let parsed: Record + try { + parsed = JSON.parse(form.getValues('config') || '{}') as Record + } catch { + return + } + const nextJson = JSON.stringify(setOptionalService(parsed, service, enabled), null, 2) + form.setValue('config', nextJson, { shouldDirty: true, shouldValidate: true }) + validateJsonContent(nextJson) + }, + [form, validateJsonContent], + ) + // Debounce config changes to improve performance const debouncedConfigChange = useCallback( debounce((value: string) => { @@ -325,6 +356,17 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit return } + const unknownServices = findUnknownApiServices(configObj) + if (unknownServices.length > 0) { + const message = t('coreConfigModal.apiServicesUnknown', { + defaultValue: 'Unrecognized API service(s): {{names}}. Remove or fix them to save.', + names: unknownServices.join(', '), + }) + form.setError('config', { type: 'manual', message }) + toast.error(message) + return + } + const backendType = values.type ?? 'xray' const fallbackTags = backendType !== 'wg' ? values.fallback_id || [] : [] const excludeInboundTags = backendType !== 'wg' ? values.excluded_inbound_ids || [] : [] @@ -968,6 +1010,41 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit )} /> + {/* API services: opt into optional Xray API services. + The node always injects the required ones regardless. */} +
+ {t('coreConfigModal.apiServices', { defaultValue: 'API Services' })} +
+ {REQUIRED_API_SERVICES.map(svc => ( +
+ + {svc} + + {t('coreConfigModal.apiServiceAlwaysOn', { defaultValue: 'always on' })} + +
+ ))} + {OPTIONAL_API_SERVICES.map(svc => ( +
+ handleToggleApiService(svc, checked === true)} + disabled={!validation.isValid} + /> + {svc} +
+ ))} + {unknownApiServices.length > 0 && ( +

+ {t('coreConfigModal.apiServicesUnknown', { + defaultValue: 'Unrecognized API service(s): {{names}}. Remove or fix them to save.', + names: unknownApiServices.join(', '), + })} +

+ )} +
+
+ {/* Enhanced TabsList with Text Overflow */} @@ -1127,7 +1204,7 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit 0 || createCoreMutation.isPending || modifyCoreMutation.isPending || form.formState.isSubmitting} isLoading={createCoreMutation.isPending || modifyCoreMutation.isPending} loadingText={editingCore ? t('modifying') : t('creating')} > From 0eb21154c300baadfea9fd3f63eaef3a74a2469d Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sat, 27 Jun 2026 14:54:22 +0330 Subject: [PATCH 4/8] fix(dashboard): associate labels with api-service checkboxes and dedupe message --- .../nodes/dialogs/core-config-modal.tsx | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/dashboard/src/features/nodes/dialogs/core-config-modal.tsx b/dashboard/src/features/nodes/dialogs/core-config-modal.tsx index ff136b34b..f9a50894f 100644 --- a/dashboard/src/features/nodes/dialogs/core-config-modal.tsx +++ b/dashboard/src/features/nodes/dialogs/core-config-modal.tsx @@ -173,11 +173,20 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit } const nextJson = JSON.stringify(setOptionalService(parsed, service, enabled), null, 2) form.setValue('config', nextJson, { shouldDirty: true, shouldValidate: true }) + // ponytail: re-serializing pretty-prints the whole config (2-space) and normalizes + // number literals — accepted per the "edit api.services in the JSON" design; + // setOptionalService preserves all other keys. validateJsonContent(nextJson) }, [form, validateJsonContent], ) + const apiServicesUnknownMessage = (names: string[]) => + t('coreConfigModal.apiServicesUnknown', { + defaultValue: 'Unrecognized API service(s): {{names}}. Remove or fix them to save.', + names: names.join(', '), + }) + // Debounce config changes to improve performance const debouncedConfigChange = useCallback( debounce((value: string) => { @@ -358,10 +367,7 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit const unknownServices = findUnknownApiServices(configObj) if (unknownServices.length > 0) { - const message = t('coreConfigModal.apiServicesUnknown', { - defaultValue: 'Unrecognized API service(s): {{names}}. Remove or fix them to save.', - names: unknownServices.join(', '), - }) + const message = apiServicesUnknownMessage(unknownServices) form.setError('config', { type: 'manual', message }) toast.error(message) return @@ -1016,31 +1022,26 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit {t('coreConfigModal.apiServices', { defaultValue: 'API Services' })}
{REQUIRED_API_SERVICES.map(svc => ( -
+
+ ))} {OPTIONAL_API_SERVICES.map(svc => ( -
+
+ ))} {unknownApiServices.length > 0 && ( -

- {t('coreConfigModal.apiServicesUnknown', { - defaultValue: 'Unrecognized API service(s): {{names}}. Remove or fix them to save.', - names: unknownApiServices.join(', '), - })} -

+

{apiServicesUnknownMessage(unknownApiServices)}

)}
From f728616d17f869a186fa689d832b7a67967f95a4 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sat, 27 Jun 2026 16:22:39 +0330 Subject: [PATCH 5/8] feat(dashboard): add xray API services section to structured core editor Adds an "API" section to the route-based core editor (Nodes > Cores), mirroring the legacy core-config modal's control. Reuses the existing xray-api-services helpers; optional services are stored on the profile's preserved top-level `api` block and persisted through the xray adapter. --- dashboard/public/statics/locales/en.json | 4 +- .../components/xray/xray-api-section.tsx | 89 +++++++++++++++++++ .../components/xray/xray-core-editor.tsx | 2 + .../core-editor/kit/core-section-nav.ts | 3 +- .../core-editor/routes/core-editor-page.tsx | 4 + .../core-editor/state/core-editor-store.ts | 2 +- 6 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 dashboard/src/features/core-editor/components/xray/xray-api-section.tsx diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index 722b35950..1f5494f13 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -2218,6 +2218,7 @@ "balancers": "Balancers", "dns": "DNS", "bindings": "Bindings", + "api": "API", "advanced": "Advanced", "interface": "Interface" }, @@ -2229,7 +2230,8 @@ "routing": "Traffic rules and routing", "balancers": "Outbound load balancing setup", "dns": "DNS resolver and server config", - "bindings": "Inbound tag fallbacks and exclusions" + "bindings": "Inbound tag fallbacks and exclusions", + "api": "Xray gRPC API services" }, "wg": { "interfaceBlurb": "WireGuard interface settings and key material for this core.", diff --git a/dashboard/src/features/core-editor/components/xray/xray-api-section.tsx b/dashboard/src/features/core-editor/components/xray/xray-api-section.tsx new file mode 100644 index 000000000..a8e1ffce2 --- /dev/null +++ b/dashboard/src/features/core-editor/components/xray/xray-api-section.tsx @@ -0,0 +1,89 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Checkbox } from '@/components/ui/checkbox' +import { useCoreEditorStore } from '@/features/core-editor/state/core-editor-store' +import { findUnknownApiServices, getSelectedOptional, OPTIONAL_API_SERVICES, REQUIRED_API_SERVICES, setOptionalService } from '@/lib/xray-api-services' +import type { JsonValue, Profile } from '@pasarguard/xray-config-kit' +import { Webhook } from 'lucide-react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +// The structured editor keeps unmodeled top-level keys (incl. `api`) on +// `profile.raw.topLevel` (see UNMODELED_TOP_LEVEL_KEYS_TO_PRESERVE in xray-adapter.ts), +// which is structurally a config object for the api-services helpers. +function readTopLevel(profile: Profile): Record { + return (profile.raw?.topLevel ?? {}) as Record +} + +function withApiService(profile: Profile, service: string, enabled: boolean): Profile { + const nextTopLevel = setOptionalService(readTopLevel(profile), service, enabled) as Record + return { + ...profile, + raw: { + ...(profile.raw ?? {}), + topLevel: nextTopLevel, + }, + } as Profile +} + +export function XrayApiSection() { + const { t } = useTranslation() + const profile = useCoreEditorStore(s => s.xrayProfile) + const updateXrayProfile = useCoreEditorStore(s => s.updateXrayProfile) + + const { selected, unknown } = useMemo(() => { + const topLevel = profile ? readTopLevel(profile) : {} + return { + selected: getSelectedOptional(topLevel), + unknown: findUnknownApiServices(topLevel), + } + }, [profile]) + + if (!profile) return null + + const toggle = (service: string, enabled: boolean) => { + updateXrayProfile(p => withApiService(p, service, enabled)) + } + + return ( +
+ + + + + {t('coreEditor.api.title', { defaultValue: 'API Services' })} + + + +

+ {t('coreEditor.api.hint', { + defaultValue: 'gRPC API services exposed by this core. The node always enables the required services; enable optional ones as needed.', + })} +

+
+ {REQUIRED_API_SERVICES.map(svc => ( + + ))} + {OPTIONAL_API_SERVICES.map(svc => ( + + ))} +
+ {unknown.length > 0 && ( +

+ {t('coreEditor.api.unknown', { + defaultValue: 'Unrecognized API service(s): {{names}}. Fix them in the Advanced tab.', + names: unknown.join(', '), + })} +

+ )} +
+
+
+ ) +} diff --git a/dashboard/src/features/core-editor/components/xray/xray-core-editor.tsx b/dashboard/src/features/core-editor/components/xray/xray-core-editor.tsx index c797929a3..547c1de92 100644 --- a/dashboard/src/features/core-editor/components/xray/xray-core-editor.tsx +++ b/dashboard/src/features/core-editor/components/xray/xray-core-editor.tsx @@ -1,5 +1,6 @@ import { XrayInboundTagSelectors } from '@/features/core-editor/components/shared/xray-inbound-tag-selectors' import { XrayAdvancedSection } from '@/features/core-editor/components/xray/xray-advanced-section' +import { XrayApiSection } from '@/features/core-editor/components/xray/xray-api-section' import { XrayBalancersSection } from '@/features/core-editor/components/xray/xray-balancers-section' import { XrayDnsSection } from '@/features/core-editor/components/xray/xray-dns-section' import { XrayInboundsSection } from '@/features/core-editor/components/xray/xray-inbounds-section' @@ -31,6 +32,7 @@ export function XrayCoreEditor({ headerAddPulse, headerAddEpoch }: XrayCoreEdito {section === 'balancers' && } {section === 'dns' && } {section === 'bindings' && } + {section === 'api' && } {section === 'advanced' && } ) diff --git a/dashboard/src/features/core-editor/kit/core-section-nav.ts b/dashboard/src/features/core-editor/kit/core-section-nav.ts index bb357d9ae..0090780bc 100644 --- a/dashboard/src/features/core-editor/kit/core-section-nav.ts +++ b/dashboard/src/features/core-editor/kit/core-section-nav.ts @@ -1,5 +1,5 @@ import type { LucideIcon } from 'lucide-react' -import { ArrowDownToLine, ArrowUpFromLine, Braces, Cable, Globe, Link2, Scale, Waypoints } from 'lucide-react' +import { ArrowDownToLine, ArrowUpFromLine, Braces, Cable, Globe, Link2, Scale, Waypoints, Webhook } from 'lucide-react' import type { WgCoreSection, XrayCoreSection } from '@/features/core-editor/state/core-editor-store' export type XraySectionNavItem = { @@ -23,6 +23,7 @@ export const XRAY_CORE_SECTION_NAV: XraySectionNavItem[] = [ { id: 'balancers', labelKey: 'coreEditor.section.balancers', defaultLabel: 'Balancers', icon: Scale }, { id: 'dns', labelKey: 'coreEditor.section.dns', defaultLabel: 'DNS', icon: Globe }, { id: 'bindings', labelKey: 'coreEditor.section.bindings', defaultLabel: 'Bindings', icon: Link2 }, + { id: 'api', labelKey: 'coreEditor.section.api', defaultLabel: 'API', icon: Webhook }, { id: 'advanced', labelKey: 'coreEditor.section.advanced', defaultLabel: 'Advanced', icon: Braces }, ] diff --git a/dashboard/src/features/core-editor/routes/core-editor-page.tsx b/dashboard/src/features/core-editor/routes/core-editor-page.tsx index aeeabb83e..0a9927860 100644 --- a/dashboard/src/features/core-editor/routes/core-editor-page.tsx +++ b/dashboard/src/features/core-editor/routes/core-editor-page.tsx @@ -506,6 +506,10 @@ export default function CoreEditorPage() { title: 'coreEditor.section.bindings', description: 'coreEditor.sectionDesc.bindings', }, + api: { + title: 'coreEditor.section.api', + description: 'coreEditor.sectionDesc.api', + }, advanced: { title: 'coreEditor.section.advanced', description: 'coreEditor.sectionDesc.advanced', diff --git a/dashboard/src/features/core-editor/state/core-editor-store.ts b/dashboard/src/features/core-editor/state/core-editor-store.ts index ca149bb3e..abb526666 100644 --- a/dashboard/src/features/core-editor/state/core-editor-store.ts +++ b/dashboard/src/features/core-editor/state/core-editor-store.ts @@ -7,7 +7,7 @@ import { apiCoreTypeToKind } from '../kit/core-kind' import { createNewXrayProfile, importRawToProfile, profileToPersistedConfig } from '../kit/xray-adapter' import { createNewWireGuardDraft, draftToPersistedConfig, wireGuardConfigToDraft } from '../kit/wireguard-adapter' -export type XrayCoreSection = 'bindings' | 'inbounds' | 'outbounds' | 'routing' | 'balancers' | 'dns' | 'advanced' +export type XrayCoreSection = 'bindings' | 'inbounds' | 'outbounds' | 'routing' | 'balancers' | 'dns' | 'api' | 'advanced' export type WgCoreSection = 'interface' | 'advanced' From 8c1eae3fe6fc97af6174c1d43ae17b310bed354a Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sun, 28 Jun 2026 11:41:04 +0330 Subject: [PATCH 6/8] fix(dashboard): persist xray api-service toggles and label the API section Unchecking the last optional api service emptied raw.topLevel.api, so the adapter override disappeared and the kit re-emitted the original api from raw.source, leaving the persisted config unchanged (Save never enabled and the removal would not stick). setRawOptionalService now edits raw.source and raw.topLevel in lockstep so toggles round-trip cleanly. The API section header rendered the raw i18n key whenever a stale (service-worker cached) locale bundle lacked the new keys. PageHeader now accepts title/description defaults, matching the codebase's defaultValue pattern, so the header reads correctly regardless of cache state. --- .../src/components/layout/page-header.tsx | 10 ++++-- .../components/xray/xray-api-section.tsx | 13 ++----- .../core-editor/routes/core-editor-page.tsx | 25 ++++++++++--- dashboard/src/lib/xray-api-services.test.ts | 35 +++++++++++++++++++ dashboard/src/lib/xray-api-services.ts | 24 +++++++++++++ 5 files changed, 89 insertions(+), 18 deletions(-) diff --git a/dashboard/src/components/layout/page-header.tsx b/dashboard/src/components/layout/page-header.tsx index 87de4966b..79a71867c 100644 --- a/dashboard/src/components/layout/page-header.tsx +++ b/dashboard/src/components/layout/page-header.tsx @@ -10,7 +10,11 @@ import { useLocation } from 'react-router' interface PageHeaderProps { title: string + /** Fallback text when the `title` i18n key is missing (e.g. a stale cached locale bundle). */ + titleDefault?: string description?: string + /** Fallback text when the `description` i18n key is missing. */ + descriptionDefault?: string buttonText?: string onButtonClick?: () => void buttonIcon?: LucideIcon @@ -19,7 +23,7 @@ interface PageHeaderProps { className?: string } -export default function PageHeader({ title, description, buttonText, onButtonClick, buttonIcon: Icon = Plus, buttonTooltip, tutorialUrl, className }: PageHeaderProps) { +export default function PageHeader({ title, titleDefault, description, descriptionDefault, buttonText, onButtonClick, buttonIcon: Icon = Plus, buttonTooltip, tutorialUrl, className }: PageHeaderProps) { const { t } = useTranslation() const dir = useDirDetection() const location = useLocation() @@ -32,7 +36,7 @@ export default function PageHeader({ title, description, buttonText, onButtonCli
-

{t(title)}

+

{t(title, titleDefault ? { defaultValue: titleDefault } : undefined)}

@@ -52,7 +56,7 @@ export default function PageHeader({ title, description, buttonText, onButtonCli
- {description && {t(description)}} + {description && {t(description, descriptionDefault ? { defaultValue: descriptionDefault } : undefined)}}
{buttonText && onButtonClick && (
diff --git a/dashboard/src/features/core-editor/components/xray/xray-api-section.tsx b/dashboard/src/features/core-editor/components/xray/xray-api-section.tsx index a8e1ffce2..a4def7c1c 100644 --- a/dashboard/src/features/core-editor/components/xray/xray-api-section.tsx +++ b/dashboard/src/features/core-editor/components/xray/xray-api-section.tsx @@ -1,8 +1,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Checkbox } from '@/components/ui/checkbox' import { useCoreEditorStore } from '@/features/core-editor/state/core-editor-store' -import { findUnknownApiServices, getSelectedOptional, OPTIONAL_API_SERVICES, REQUIRED_API_SERVICES, setOptionalService } from '@/lib/xray-api-services' -import type { JsonValue, Profile } from '@pasarguard/xray-config-kit' +import { findUnknownApiServices, getSelectedOptional, OPTIONAL_API_SERVICES, REQUIRED_API_SERVICES, setRawOptionalService } from '@/lib/xray-api-services' +import type { Profile } from '@pasarguard/xray-config-kit' import { Webhook } from 'lucide-react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -15,14 +15,7 @@ function readTopLevel(profile: Profile): Record { } function withApiService(profile: Profile, service: string, enabled: boolean): Profile { - const nextTopLevel = setOptionalService(readTopLevel(profile), service, enabled) as Record - return { - ...profile, - raw: { - ...(profile.raw ?? {}), - topLevel: nextTopLevel, - }, - } as Profile + return { ...profile, raw: setRawOptionalService(profile.raw, service, enabled) } as Profile } export function XrayApiSection() { diff --git a/dashboard/src/features/core-editor/routes/core-editor-page.tsx b/dashboard/src/features/core-editor/routes/core-editor-page.tsx index 0a9927860..186c6a55d 100644 --- a/dashboard/src/features/core-editor/routes/core-editor-page.tsx +++ b/dashboard/src/features/core-editor/routes/core-editor-page.tsx @@ -31,6 +31,15 @@ import useDirDetection from '@/hooks/use-dir-detection' type LoadingCoreKind = 'xray' | 'wg' +type SectionHeaderConfig = { + title: string + /** Fallback shown when the `title` key is missing from a stale cached locale bundle. */ + titleDefault?: string + description?: string + descriptionDefault?: string + buttonText?: string +} + function loadingSectionPageHeaderProps(coreKind?: LoadingCoreKind): { title: string; description?: string } { if (coreKind === 'wg') { return { @@ -461,10 +470,10 @@ export default function CoreEditorPage() {
) - const sectionHeaderConfig = useMemo(() => { + const sectionHeaderConfig: SectionHeaderConfig | undefined = useMemo(() => { if (kind === 'wg') { const section = activeSection as WgCoreSection - return { + const map: Record = { interface: { title: 'coreEditor.section.interface', description: 'coreEditor.sectionDesc.wgInterface', @@ -473,11 +482,12 @@ export default function CoreEditorPage() { title: 'coreEditor.section.advanced', description: 'coreEditor.sectionDesc.advanced', }, - }[section] + } + return map[section] } const section = activeSection as XrayCoreSection - return { + const map: Record = { inbounds: { title: 'coreEditor.section.inbounds', description: 'coreEditor.sectionDesc.inbounds', @@ -508,13 +518,16 @@ export default function CoreEditorPage() { }, api: { title: 'coreEditor.section.api', + titleDefault: 'API', description: 'coreEditor.sectionDesc.api', + descriptionDefault: 'Xray gRPC API services', }, advanced: { title: 'coreEditor.section.advanced', description: 'coreEditor.sectionDesc.advanced', }, - }[section] + } + return map[section] }, [kind, activeSection]) if (!isNew && validId && isLoading) { @@ -563,7 +576,9 @@ export default function CoreEditorPage() { sectionHeaderConfig ? ( setHeaderAddPulse(p => ({ target: String(activeSection), n: p.n + 1 })) : undefined} diff --git a/dashboard/src/lib/xray-api-services.test.ts b/dashboard/src/lib/xray-api-services.test.ts index 719fa9ba6..0f7abdd3d 100644 --- a/dashboard/src/lib/xray-api-services.test.ts +++ b/dashboard/src/lib/xray-api-services.test.ts @@ -8,6 +8,7 @@ import { OPTIONAL_API_SERVICES, REQUIRED_API_SERVICES, setOptionalService, + setRawOptionalService, } from './xray-api-services.ts' test('getSelectedOptional: no api yields empty', () => { @@ -89,3 +90,37 @@ test('CANONICAL_API_SERVICES maps exactly REQUIRED ∪ OPTIONAL (both directions test('getSelectedOptional: trims and dedupes whitespace/case variants', () => { assert.deepEqual(getSelectedOptional({ api: { services: [' routingservice ', 'RoutingService'] } }), ['RoutingService']) }) + +// Regression: the kit re-emits `api` from `raw.source`, so a removal expressed only +// on `raw.topLevel` is undone (Save never enables). setRawOptionalService must edit both. +test('setRawOptionalService: disabling the last service clears api from BOTH source and topLevel', () => { + const raw = { + source: { api: { services: ['RoutingService'] }, log: { loglevel: 'warning' } }, + topLevel: { api: { services: ['RoutingService'] } }, + } + assert.deepEqual(setRawOptionalService(raw, 'RoutingService', false), { + source: { log: { loglevel: 'warning' } }, + topLevel: {}, + }) +}) + +test('setRawOptionalService: enabling adds the service to BOTH source and topLevel', () => { + assert.deepEqual(setRawOptionalService({ source: { log: {} }, topLevel: {} }, 'RoutingService', true), { + source: { log: {}, api: { services: ['RoutingService'] } }, + topLevel: { api: { services: ['RoutingService'] } }, + }) +}) + +test('setRawOptionalService: preserves other raw keys and only touches api', () => { + const raw = { extra: 1, source: { api: { services: ['RoutingService', 'ObservatoryService'] }, routing: { rules: [] } }, topLevel: { api: { services: ['RoutingService', 'ObservatoryService'] }, policy: {} } } + assert.deepEqual(setRawOptionalService(raw, 'RoutingService', false), { + extra: 1, + source: { api: { services: ['ObservatoryService'] }, routing: { rules: [] } }, + topLevel: { api: { services: ['ObservatoryService'] }, policy: {} }, + }) +}) + +test('setRawOptionalService: missing source updates only topLevel; non-object raw is safe', () => { + assert.deepEqual(setRawOptionalService({ topLevel: {} }, 'RoutingService', true), { topLevel: { api: { services: ['RoutingService'] } } }) + assert.deepEqual(setRawOptionalService(undefined, 'RoutingService', true), { topLevel: { api: { services: ['RoutingService'] } } }) +}) diff --git a/dashboard/src/lib/xray-api-services.ts b/dashboard/src/lib/xray-api-services.ts index c95fce862..9fcb1dccd 100644 --- a/dashboard/src/lib/xray-api-services.ts +++ b/dashboard/src/lib/xray-api-services.ts @@ -92,3 +92,27 @@ export function setOptionalService( } return next } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +// Toggle an optional service on a structured-editor profile's `raw`. +// +// The structured editor holds two copies of the unmodeled `api` section: +// - `raw.source` — the original config the kit re-emits verbatim on compile, and +// - `raw.topLevel` — the adapter's override, which only wins when the `api` key is +// present (see applyUnmodeledTopLevelSectionsToCompiledConfig in xray-adapter.ts). +// Editing only `topLevel` desyncs them: removing the last optional service deletes +// `topLevel.api`, the override vanishes, and the original `api` resurfaces from +// `raw.source` — so the change is silently dropped (no diff -> Save stays disabled). +// Apply the same edit to both so toggles always persist and round-trip cleanly. +export function setRawOptionalService(raw: unknown, service: string, enabled: boolean): Record { + const base = isRecord(raw) ? raw : {} + const topLevel = isRecord(base.topLevel) ? base.topLevel : {} + const next: Record = { ...base, topLevel: setOptionalService(topLevel, service, enabled) } + if (isRecord(base.source)) { + next.source = setOptionalService(base.source, service, enabled) + } + return next +} From dbbde138432a833e74a7ea3dc7bcde12546a4562 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sun, 28 Jun 2026 11:44:53 +0330 Subject: [PATCH 7/8] chore: ignore local superpowers tooling artifacts --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8e2d5f1bf..6b2347651 100644 --- a/.gitignore +++ b/.gitignore @@ -172,3 +172,4 @@ deadlock-analysis.md # AI .AGENT graphify-out +superpowers/ \ No newline at end of file From e9152f2d2165da26f0e4823679225bbedc3a126e Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sun, 28 Jun 2026 11:55:55 +0330 Subject: [PATCH 8/8] fix(locales): translate xray API editor section for all locales The structured core editor's API section was only partly localized: - coreEditor.section.api / sectionDesc.api existed in en.json only, so fa/ru/zh fell back to English for the section header. - coreEditor.api.title / api.hint (the section card title and hint) were inline English defaultValues, absent from every locale file. Add the section header keys to fa/ru/zh and the card title + hint to all four locales (the "API" acronym is kept; descriptive text translated). --- dashboard/public/statics/locales/en.json | 4 ++++ dashboard/public/statics/locales/fa.json | 8 +++++++- dashboard/public/statics/locales/ru.json | 8 +++++++- dashboard/public/statics/locales/zh.json | 8 +++++++- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index 1f5494f13..8a0043f2c 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -2233,6 +2233,10 @@ "bindings": "Inbound tag fallbacks and exclusions", "api": "Xray gRPC API services" }, + "api": { + "title": "API Services", + "hint": "gRPC API services exposed by this core. The node always enables the required services; enable optional ones as needed." + }, "wg": { "interfaceBlurb": "WireGuard interface settings and key material for this core.", "keysRegenerated": "New keypair generated", diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index 392654359..85df9c5c8 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -2133,6 +2133,7 @@ "balancers": "متعادل‌کننده‌ها", "dns": "DNS", "bindings": "اتصال‌ها", + "api": "API", "advanced": "پیشرفته", "interface": "رابط" }, @@ -2144,7 +2145,12 @@ "routing": "قوانین مسیریابی و ترافیک", "balancers": "استراتژی‌های متعادل‌سازی بار", "dns": "پیکربندی DNS و حل‌کننده", - "bindings": "پشتیبان و مستثنا برای برچسب‌های ورودی" + "bindings": "پشتیبان و مستثنا برای برچسب‌های ورودی", + "api": "سرویس‌های gRPC API مربوط به Xray" + }, + "api": { + "title": "سرویس‌های API", + "hint": "سرویس‌های gRPC API که این هسته ارائه می‌دهد. گره همیشه سرویس‌های موردنیاز را فعال می‌کند؛ موارد اختیاری را در صورت نیاز فعال کنید." }, "wg": { "interfaceBlurb": "تنظیمات رابط وایرگارد و مواد کلیدی این هسته.", diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index 0cec30c9e..456ccf5ad 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -2107,6 +2107,7 @@ "balancers": "Балансировщики", "dns": "DNS", "bindings": "Привязки", + "api": "API", "advanced": "Дополнительно", "interface": "Интерфейс" }, @@ -2118,7 +2119,12 @@ "routing": "Правила маршрутизации трафика", "balancers": "Стратегии балансировки нагрузки", "dns": "Настройки DNS и резолвера", - "bindings": "Резервные и исключённые теги входящих" + "bindings": "Резервные и исключённые теги входящих", + "api": "Сервисы gRPC API Xray" + }, + "api": { + "title": "Сервисы API", + "hint": "Сервисы gRPC API, предоставляемые этим ядром. Узел всегда включает обязательные сервисы; необязательные включайте по мере необходимости." }, "wg": { "interfaceBlurb": "Настройки интерфейса WireGuard и ключи для этого ядра.", diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index 1b5dc65f3..a17542a96 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -2178,6 +2178,7 @@ "balancers": "负载均衡", "dns": "DNS", "bindings": "绑定", + "api": "API", "advanced": "高级", "interface": "接口" }, @@ -2189,7 +2190,12 @@ "routing": "路由规则与流量策略", "balancers": "负载均衡与调度策略", "dns": "DNS 解析与解析器", - "bindings": "入站标签回退与排除" + "bindings": "入站标签回退与排除", + "api": "Xray gRPC API 服务" + }, + "api": { + "title": "API 服务", + "hint": "此核心提供的 gRPC API 服务。节点始终启用必需的服务;可按需启用可选服务。" }, "wg": { "interfaceBlurb": "此核心的 WireGuard 接口与密钥设置。",