From b63b6120040f53b681ac01a56421edd17a573941 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sat, 27 Jun 2026 14:23:08 +0330 Subject: [PATCH 01/13] 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 d5ec192e80e0901da629917f14a817a4e556a179 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sat, 27 Jun 2026 14:36:11 +0330 Subject: [PATCH 02/13] 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 ffa375166..bb6295cc2 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 d3d59e499a8ec6cb399cb492b2bfcea957f9d9e6 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sat, 27 Jun 2026 14:42:56 +0330 Subject: [PATCH 03/13] 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 35444db8fcf692edacabd3e3726d34e937b85f23 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sat, 27 Jun 2026 14:54:22 +0330 Subject: [PATCH 04/13] 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 65f00c99d8162d201e2618a11af655116e043300 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sat, 27 Jun 2026 16:22:39 +0330 Subject: [PATCH 05/13] 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 96ad3db7e..0419939bb 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -2283,6 +2283,7 @@ "balancers": "Balancers", "dns": "DNS", "bindings": "Bindings", + "api": "API", "advanced": "Advanced", "interface": "Interface" }, @@ -2294,7 +2295,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 612906220..426ff1471 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 cf92bf7f510d4820dcf8f3cc9dfa278091f4b585 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sun, 28 Jun 2026 11:41:04 +0330 Subject: [PATCH 06/13] 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 426ff1471..6fc15655e 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 f4114cacb10be993d4d5b5b78ae18a76fd6c328b Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sun, 28 Jun 2026 11:44:53 +0330 Subject: [PATCH 07/13] 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 e2283755e2a5c0eb52f4221ae68505fca17a3da7 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Sun, 28 Jun 2026 11:55:55 +0330 Subject: [PATCH 08/13] 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 0419939bb..0f956e494 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -2298,6 +2298,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 5217e27f5..5377bed0a 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -2197,6 +2197,7 @@ "balancers": "متعادل‌کننده‌ها", "dns": "DNS", "bindings": "اتصال‌ها", + "api": "API", "advanced": "پیشرفته", "interface": "رابط" }, @@ -2208,7 +2209,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 c656e5465..50df6cfd2 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -2170,6 +2170,7 @@ "balancers": "Балансировщики", "dns": "DNS", "bindings": "Привязки", + "api": "API", "advanced": "Дополнительно", "interface": "Интерфейс" }, @@ -2181,7 +2182,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 400a76062..5ee74d9c5 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -2241,6 +2241,7 @@ "balancers": "负载均衡", "dns": "DNS", "bindings": "绑定", + "api": "API", "advanced": "高级", "interface": "接口" }, @@ -2252,7 +2253,12 @@ "routing": "路由规则与流量策略", "balancers": "负载均衡与调度策略", "dns": "DNS 解析与解析器", - "bindings": "入站标签回退与排除" + "bindings": "入站标签回退与排除", + "api": "Xray gRPC API 服务" + }, + "api": { + "title": "API 服务", + "hint": "此核心提供的 gRPC API 服务。节点始终启用必需的服务;可按需启用可选服务。" }, "wg": { "interfaceBlurb": "此核心的 WireGuard 接口与密钥设置。", From 1acea7547dcefad0aa1f1b467241a0c0ffa826ec Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Wed, 15 Jul 2026 01:47:33 +0330 Subject: [PATCH 09/13] fix(dashboard): scope api-service validation to xray and guard non-object json api.services is Xray-only, but the submit validation ran before the backend type was read, so a WireGuard config carrying an api.services block could be rejected by Xray rules; the derivation memo was likewise unscoped. Both now gate on the backend type, matching the existing isXrayBackend render gate. handleToggleApiService also accepted any valid JSON: toggling on a non-object config (null/array/primitive) silently replaced or mangled it on re-serialize. Toggles now early-return unless the config is a plain object. Addresses CodeRabbit review on #662. --- .../nodes/dialogs/core-config-modal.tsx | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/dashboard/src/features/nodes/dialogs/core-config-modal.tsx b/dashboard/src/features/nodes/dialogs/core-config-modal.tsx index f9a50894f..4a68f34e2 100644 --- a/dashboard/src/features/nodes/dialogs/core-config-modal.tsx +++ b/dashboard/src/features/nodes/dialogs/core-config-modal.tsx @@ -149,8 +149,12 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit ) // Optional Xray API services derived from the config JSON (the JSON is the source of truth). + // api.services is Xray-only, so WireGuard configs are never derived or validated against it. const watchedConfig = form.watch('config') const { selectedApiServices, unknownApiServices } = useMemo(() => { + if (!isXrayBackend) { + return { selectedApiServices: [], unknownApiServices: [] } + } let parsed: unknown = null try { parsed = JSON.parse(watchedConfig || '{}') @@ -161,13 +165,19 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit selectedApiServices: getSelectedOptional(parsed), unknownApiServices: findUnknownApiServices(parsed), } - }, [watchedConfig]) + }, [isXrayBackend, watchedConfig]) const handleToggleApiService = useCallback( (service: string, enabled: boolean) => { let parsed: Record try { - parsed = JSON.parse(form.getValues('config') || '{}') as Record + // Valid-but-non-object JSON (null/array/primitive) would be silently replaced + // or mangled by the re-serialize below, so only plain objects are toggled. + const candidate: unknown = JSON.parse(form.getValues('config') || '{}') + if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) { + return + } + parsed = candidate as Record } catch { return } @@ -365,15 +375,19 @@ export default function CoreConfigModal({ isDialogOpen, onOpenChange, form, edit return } - const unknownServices = findUnknownApiServices(configObj) - if (unknownServices.length > 0) { - const message = apiServicesUnknownMessage(unknownServices) - form.setError('config', { type: 'manual', message }) - toast.error(message) - return + const backendType = values.type ?? 'xray' + + // api.services is Xray-only; never block a WireGuard config on Xray rules. + if (backendType !== 'wg') { + const unknownServices = findUnknownApiServices(configObj) + if (unknownServices.length > 0) { + const message = apiServicesUnknownMessage(unknownServices) + 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 || [] : [] From dc40d39c00d5dfb54e05e2b5170bb58ed25b1497 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Wed, 15 Jul 2026 01:47:33 +0330 Subject: [PATCH 10/13] fix(dashboard): never write required api services from setOptionalService The helper resolved names via CANONICAL_API_SERVICES, which also contains the required services, so a caller could write HandlerService into api.services -- contradicting the documented invariant that the panel never writes them (the node injects required services itself). Restrict the lookup to the optional set and pin the invariant with enable/disable no-op tests. Addresses CodeRabbit review on #662. --- dashboard/src/lib/xray-api-services.test.ts | 11 +++++++++++ dashboard/src/lib/xray-api-services.ts | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/dashboard/src/lib/xray-api-services.test.ts b/dashboard/src/lib/xray-api-services.test.ts index 0f7abdd3d..d746b2ce4 100644 --- a/dashboard/src/lib/xray-api-services.test.ts +++ b/dashboard/src/lib/xray-api-services.test.ts @@ -71,6 +71,17 @@ test('setOptionalService: unknown service name is a no-op', () => { assert.deepEqual(setOptionalService({ api: { services: [] } }, 'Nope', true), { api: { services: [] } }) }) +// Required services are node-injected; the panel must never write (or strip) them. +test('setOptionalService: enabling a required service is a no-op', () => { + const config = { api: { services: ['RoutingService'] } } + assert.equal(setOptionalService(config, 'HandlerService', true), config) +}) + +test('setOptionalService: disabling a required service is a no-op', () => { + const config = { api: { services: ['StatsService'] } } + assert.equal(setOptionalService(config, 'StatsService', false), config) +}) + 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']) diff --git a/dashboard/src/lib/xray-api-services.ts b/dashboard/src/lib/xray-api-services.ts index 9fcb1dccd..a178358e2 100644 --- a/dashboard/src/lib/xray-api-services.ts +++ b/dashboard/src/lib/xray-api-services.ts @@ -64,7 +64,9 @@ export function setOptionalService( enabled: boolean, ): Record { const key = service.trim().toLowerCase() - const canonical = CANONICAL_API_SERVICES[key] + // Only optional services may be written: required ones are node-injected and the + // panel never writes them, so a required (or unknown) name is a no-op. + const canonical = OPTIONAL_API_SERVICES.find(candidate => candidate.toLowerCase() === key) if (!canonical) return config const next: Record = { ...config } From 81fadea917cbee557b4130bab6c8026c0dcfc898 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Wed, 15 Jul 2026 01:47:33 +0330 Subject: [PATCH 11/13] fix(locales): add missing api-services strings in all locales coreEditor.api.alwaysOn / .unknown were read by the structured editor's API section but missing from every locale, so fa/ru/zh fell back to the inline English defaults. The legacy modal's coreConfigModal.apiServices / apiServiceAlwaysOn / apiServicesUnknown keys had the same gap. Add all of them to en/fa/ru/zh (with the {{names}} interpolation preserved). Addresses CodeRabbit review on #662. --- dashboard/public/statics/locales/en.json | 7 ++++++- dashboard/public/statics/locales/fa.json | 7 ++++++- dashboard/public/statics/locales/ru.json | 7 ++++++- dashboard/public/statics/locales/zh.json | 7 ++++++- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index 0f956e494..3aa19f7e8 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -2164,6 +2164,9 @@ "excludedInbound": "Excluded inbound", "selectInbound": "Select inbound", "inbound": "Inbound", + "apiServices": "API Services", + "apiServiceAlwaysOn": "always on", + "apiServicesUnknown": "Unrecognized API service(s): {{names}}. Remove or fix them to save.", "generateKeyPair": "Key Pair", "generateShortId": "Short ID", "backendType": "Type", @@ -2300,7 +2303,9 @@ }, "api": { "title": "API Services", - "hint": "gRPC API services exposed by this core. The node always enables the required services; enable optional ones as needed." + "hint": "gRPC API services exposed by this core. The node always enables the required services; enable optional ones as needed.", + "alwaysOn": "always on", + "unknown": "Unrecognized API service(s): {{names}}. Fix them in the Advanced tab." }, "wg": { "interfaceBlurb": "WireGuard interface settings and key material for this core.", diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index 5377bed0a..885d7b1ad 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -2079,6 +2079,9 @@ "excludedInbound": "ورودی‌های مستثنی", "selectInbound": "ورودی را انتخاب کنید", "inbound": "ورودی", + "apiServices": "سرویس‌های API", + "apiServiceAlwaysOn": "همیشه فعال", + "apiServicesUnknown": "سرویس(های) API ناشناخته: {{names}}. برای ذخیره آن‌ها را حذف یا اصلاح کنید.", "generateKeyPair": "جفت کلید", "backendType": "نوع", "generateWireGuardKeyPair": "جفت‌کلید WireGuard", @@ -2214,7 +2217,9 @@ }, "api": { "title": "سرویس‌های API", - "hint": "سرویس‌های gRPC API که این هسته ارائه می‌دهد. گره همیشه سرویس‌های موردنیاز را فعال می‌کند؛ موارد اختیاری را در صورت نیاز فعال کنید." + "hint": "سرویس‌های gRPC API که این هسته ارائه می‌دهد. گره همیشه سرویس‌های موردنیاز را فعال می‌کند؛ موارد اختیاری را در صورت نیاز فعال کنید.", + "alwaysOn": "همیشه فعال", + "unknown": "سرویس(های) API ناشناخته: {{names}}. آن‌ها را در تب پیشرفته اصلاح کنید." }, "wg": { "interfaceBlurb": "تنظیمات رابط وایرگارد و مواد کلیدی این هسته.", diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index 50df6cfd2..c1662054b 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -2052,6 +2052,9 @@ "excludedInbound": "Исключенные входящие", "selectInbound": "Выберите входящий", "inbound": "Входящий", + "apiServices": "Сервисы API", + "apiServiceAlwaysOn": "всегда включён", + "apiServicesUnknown": "Нераспознанные сервисы API: {{names}}. Удалите или исправьте их, чтобы сохранить.", "generateKeyPair": "Пара ключей", "backendType": "Тип", "generateWireGuardKeyPair": "Пара ключей WireGuard", @@ -2187,7 +2190,9 @@ }, "api": { "title": "Сервисы API", - "hint": "Сервисы gRPC API, предоставляемые этим ядром. Узел всегда включает обязательные сервисы; необязательные включайте по мере необходимости." + "hint": "Сервисы gRPC API, предоставляемые этим ядром. Узел всегда включает обязательные сервисы; необязательные включайте по мере необходимости.", + "alwaysOn": "всегда включён", + "unknown": "Нераспознанные сервисы API: {{names}}. Исправьте их на вкладке «Дополнительно»." }, "wg": { "interfaceBlurb": "Настройки интерфейса WireGuard и ключи для этого ядра.", diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index 5ee74d9c5..a7ec6bdcb 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -2123,6 +2123,9 @@ "excludedInbound": "排除的入站", "selectInbound": "选择入站", "inbound": "入站", + "apiServices": "API 服务", + "apiServiceAlwaysOn": "始终启用", + "apiServicesUnknown": "无法识别的 API 服务:{{names}}。请删除或修复后再保存。", "generateKeyPair": "密钥对", "backendType": "类型", "generateWireGuardKeyPair": "WireGuard 密钥对", @@ -2258,7 +2261,9 @@ }, "api": { "title": "API 服务", - "hint": "此核心提供的 gRPC API 服务。节点始终启用必需的服务;可按需启用可选服务。" + "hint": "此核心提供的 gRPC API 服务。节点始终启用必需的服务;可按需启用可选服务。", + "alwaysOn": "始终启用", + "unknown": "无法识别的 API 服务:{{names}}。请在“高级”选项卡中修复。" }, "wg": { "interfaceBlurb": "此核心的 WireGuard 接口与密钥设置。", From 441c1796176743b0e3013715b2b90339e389eddd Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Wed, 15 Jul 2026 01:47:33 +0330 Subject: [PATCH 12/13] chore(dashboard): pin minimum node for the test script The test script runs node --test over .ts files, which relies on native type stripping (default since Node 22.18/23.6) and test-runner globs; the repo only pinned bun. Document the real constraint with an engines field. Addresses CodeRabbit review on #662. --- dashboard/package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dashboard/package.json b/dashboard/package.json index bb6295cc2..030912f53 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -2,6 +2,9 @@ "name": "pasarguard-dashboard", "private": true, "version": "5.1.0", + "engines": { + "node": ">=22.18.0" + }, "scripts": { "dev": "vite dev", "build": "vite build", From 7da718069b8a30b9de336c63ac697bec54c937d9 Mon Sep 17 00:00:00 2001 From: multi-engineer Date: Thu, 16 Jul 2026 12:15:05 +0330 Subject: [PATCH 13/13] fix(locales): translate admins.disabled placeholders in fa/ru/zh The key landed in #549 with "??"-style placeholder values in every non-English locale (zh "??", fa "???????", ru "????????"), so the label rendered as question marks. Fill in the proper translations, matching each locale's existing "disabled" terminology. Pre-existing on dev (not from this branch); surfaced by CodeRabbit's incremental review after the rebase. Fixed here since it's a one-line-per-file locale correction. --- dashboard/public/statics/locales/fa.json | 2 +- dashboard/public/statics/locales/ru.json | 2 +- dashboard/public/statics/locales/zh.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index 885d7b1ad..f21e4b477 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -614,7 +614,7 @@ "traffic": "نظارت بر مصرف ترافیک مدیر در طول زمان", "no_traffic": "داده‌ای برای ترافیک موجود نیست" }, - "disabled": "???????" + "disabled": "غیرفعال" }, "apiKeys": { "title": "کلیدهای API", diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index c1662054b..15896a011 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -731,7 +731,7 @@ "traffic": "Мониторинг использования трафика администратором во времени", "no_traffic": "Данные о трафике отсутствуют" }, - "disabled": "????????" + "disabled": "Отключен" }, "apiKeys": { "title": "API ключи", diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index a7ec6bdcb..1243b1734 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -745,7 +745,7 @@ "traffic": "监控管理员使用情况", "no_traffic": "没有使用情况数据" }, - "disabled": "??" + "disabled": "已禁用" }, "apiKeys": { "title": "API 密钥",