diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index e142a0af8..fd4b0bfe0 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -9769,6 +9769,36 @@ } } }, + "/panel/api/setting/factoryDefaults": { + "post": { + "tags": [ + "Settings" + ], + "summary": "Return the shipped (factory) default value per browser-safe setting key, so clients can tell a stored value apart from the default it would fall back to. Per-install material (secret, panelGuid, mTLS keys) and credential fields are never included.", + "operationId": "post_panel_api_setting_factoryDefaults", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + } + } + } + } + } + } + }, "/panel/api/setting/update": { "post": { "tags": [ diff --git a/frontend/src/api/queries/useFactoryDefaults.ts b/frontend/src/api/queries/useFactoryDefaults.ts new file mode 100644 index 000000000..3091a74ac --- /dev/null +++ b/frontend/src/api/queries/useFactoryDefaults.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query'; + +import { HttpUtil } from '@/utils'; +import { parseMsg } from '@/utils/zodValidate'; +import { FactoryDefaultsSchema, type FactoryDefaults } from '@/schemas/setting'; +import { keys } from '@/api/queryKeys'; + +async function fetchFactoryDefaults(): Promise { + const msg = await HttpUtil.post('/panel/api/setting/factoryDefaults', undefined, { silent: true }); + if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch factory defaults'); + const validated = parseMsg(msg, FactoryDefaultsSchema, 'setting/factoryDefaults'); + const parsed = FactoryDefaultsSchema.safeParse(validated.obj); + return parsed.success ? parsed.data : {}; +} + +export function useFactoryDefaults() { + return useQuery({ + queryKey: keys.settings.factoryDefaults(), + queryFn: fetchFactoryDefaults, + staleTime: Infinity, + }); +} diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index 4166c28c1..abcb9b1bd 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -17,6 +17,7 @@ export const keys = { root: () => ['settings'] as const, all: () => ['settings', 'all'] as const, defaults: () => ['settings', 'defaults'] as const, + factoryDefaults: () => ['settings', 'factoryDefaults'] as const, }, inbounds: { root: () => ['inbounds'] as const, diff --git a/frontend/src/components/ui/DefaultSettingTag.tsx b/frontend/src/components/ui/DefaultSettingTag.tsx new file mode 100644 index 000000000..d57e6376c --- /dev/null +++ b/frontend/src/components/ui/DefaultSettingTag.tsx @@ -0,0 +1,37 @@ +import { Tag } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import { useFactoryDefaults } from '@/api/queries/useFactoryDefaults'; + +/** + * Value semantics on purpose: the tag answers "does this equal the shipped + * default?", not "has the user ever saved this key?" — a stored 2096 and a + * fallback 2096 behave identically, so they read identically. + */ +export function matchesFactoryDefault(current: unknown, factoryDefault: string | undefined): boolean { + if (factoryDefault === undefined) return false; + if (typeof current === 'number') { + const parsed = Number(factoryDefault); + return factoryDefault.trim() !== '' && !Number.isNaN(parsed) && parsed === current; + } + if (typeof current === 'boolean') { + if (factoryDefault !== 'true' && factoryDefault !== 'false') return false; + return (factoryDefault === 'true') === current; + } + if (typeof current === 'string') return factoryDefault === current; + return false; +} + +interface DefaultSettingTagProps { + settingKey: string; + value: unknown; +} + +export default function DefaultSettingTag({ settingKey, value }: DefaultSettingTagProps) { + const { t } = useTranslation(); + const defaults = useFactoryDefaults(); + + if (!matchesFactoryDefault(value, defaults.data?.[settingKey])) return null; + + return {t('pages.settings.defaultTag')}; +} diff --git a/frontend/src/components/ui/SettingListItem.tsx b/frontend/src/components/ui/SettingListItem.tsx index 770dfbba7..3635a9b5b 100644 --- a/frontend/src/components/ui/SettingListItem.tsx +++ b/frontend/src/components/ui/SettingListItem.tsx @@ -5,6 +5,7 @@ import './SettingListItem.css'; interface SettingListItemProps { paddings?: 'small' | 'default'; title?: ReactNode; + badge?: ReactNode; description?: ReactNode; children?: ReactNode; control?: ReactNode; @@ -13,6 +14,7 @@ interface SettingListItemProps { export default function SettingListItem({ paddings = 'default', title, + badge, description, children, control, @@ -28,7 +30,12 @@ export default function SettingListItem({
- {title &&
{title}
} + {title && ( +
+ {title} + {badge} +
+ )} {description &&
{description}
}
diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 1e8121b57..c67a5df41 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -1,3 +1,4 @@ export { default as InputAddon } from './InputAddon'; export { default as InfinityIcon } from './InfinityIcon'; export { default as SettingListItem } from './SettingListItem'; +export { default as DefaultSettingTag } from './DefaultSettingTag'; diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts index c5f3d5f16..fb607aefe 100644 --- a/frontend/src/models/setting.ts +++ b/frontend/src/models/setting.ts @@ -91,7 +91,7 @@ export class AllSetting { ldapDefaultTotalGB = 0; ldapDefaultExpiryDays = 0; ldapDefaultLimitIP = 0; - tgEnabledEvents = ''; + tgEnabledEvents = 'login.attempt,cpu.high'; smtpEnable = false; smtpHost = ''; smtpPort = 587; @@ -101,7 +101,7 @@ export class AllSetting { smtpFromName = ''; smtpTo = ''; smtpEncryptionType = 'starttls'; - smtpEnabledEvents = ''; + smtpEnabledEvents = 'login.attempt,cpu.high'; smtpCpu = 80; smtpMemory = 80; outboundDownThreshold = 3; diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index f7e4c2213..b4262b1b7 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -1169,6 +1169,11 @@ export const sections: readonly Section[] = [ path: '/panel/api/setting/defaultSettings', summary: 'Return the computed default settings based on the request host. Useful to preview what a fresh install would use.', }, + { + method: 'POST', + path: '/panel/api/setting/factoryDefaults', + summary: 'Return the shipped (factory) default value per browser-safe setting key, so clients can tell a stored value apart from the default it would fall back to. Per-install material (secret, panelGuid, mTLS keys) and credential fields are never included.', + }, { method: 'POST', path: '/panel/api/setting/update', diff --git a/frontend/src/pages/settings/EmailTab.tsx b/frontend/src/pages/settings/EmailTab.tsx index e5e6a2780..fcb75fcf9 100644 --- a/frontend/src/pages/settings/EmailTab.tsx +++ b/frontend/src/pages/settings/EmailTab.tsx @@ -5,7 +5,7 @@ import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons'; import { HttpUtil } from '@/utils'; import { onNumber } from '@/utils/onNumber'; import type { AllSetting } from '@/models/setting'; -import { SettingListItem } from '@/components/ui'; +import { DefaultSettingTag, SettingListItem } from '@/components/ui'; import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { catTabLabel } from './catTabLabel'; @@ -63,7 +63,7 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) { onChange={(e) => updateSetting({ smtpHost: e.target.value })} /> - + } description={t('pages.settings.smtpPortDesc')}> updateSetting({ smtpPort: v }))} /> diff --git a/frontend/src/pages/settings/GeneralTab.tsx b/frontend/src/pages/settings/GeneralTab.tsx index f98129a55..fa52755a4 100644 --- a/frontend/src/pages/settings/GeneralTab.tsx +++ b/frontend/src/pages/settings/GeneralTab.tsx @@ -18,7 +18,7 @@ import { import type { AllSetting } from '@/models/setting'; import { HttpUtil, LanguageManager } from '@/utils'; import { onNumber } from '@/utils/onNumber'; -import { SettingListItem } from '@/components/ui'; +import { DefaultSettingTag, SettingListItem } from '@/components/ui'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { catTabLabel } from './catTabLabel'; import { sanitizePath } from './uriPath'; @@ -169,7 +169,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp updateSetting({ webDomain: e.target.value })} /> - + } description={t('pages.settings.panelPortDesc')}> updateSetting({ webPort: v }))} /> @@ -178,7 +178,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp updateSetting({ webBasePath: sanitizePath(e.target.value) })} /> - + } description={t('pages.settings.sessionMaxAgeDesc')}> updateSetting({ sessionMaxAge: v }))} /> @@ -207,7 +207,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp /> - + } description={t('pages.settings.pageSizeDesc')}> updateSetting({ pageSize: v }))} /> @@ -233,11 +233,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp label: catTabLabel(, t('pages.settings.notifications'), isMobile), children: ( <> - + } description={t('pages.settings.expireTimeDiffDesc')}> updateSetting({ expireDiff: v }))} /> - + } description={t('pages.settings.trafficDiffDesc')}> updateSetting({ trafficDiff: v }))} /> @@ -307,7 +307,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp updateSetting({ ldapHost: e.target.value })} /> - + }> updateSetting({ ldapPort: v }))} /> @@ -386,15 +386,15 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp updateSetting({ ldapAutoDelete: v })} /> - + }> updateSetting({ ldapDefaultTotalGB: v }))} /> - + }> updateSetting({ ldapDefaultExpiryDays: v }))} /> - + }> updateSetting({ ldapDefaultLimitIP: v }))} /> diff --git a/frontend/src/pages/settings/SubscriptionGeneralTab.tsx b/frontend/src/pages/settings/SubscriptionGeneralTab.tsx index 4cb23023f..4799aa3b6 100644 --- a/frontend/src/pages/settings/SubscriptionGeneralTab.tsx +++ b/frontend/src/pages/settings/SubscriptionGeneralTab.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; import type { AllSetting } from '@/models/setting'; import { onNumber } from '@/utils/onNumber'; -import { SettingListItem } from '@/components/ui'; +import { DefaultSettingTag, SettingListItem } from '@/components/ui'; import { RemarkTemplateField } from '@/components/form'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { catTabLabel } from './catTabLabel'; @@ -56,7 +56,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su updateSetting({ subDomain: e.target.value })} /> - + } description={t('pages.settings.subPortDesc')}> updateSetting({ subPort: v }))} /> @@ -105,7 +105,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su /> - + } description={t('pages.settings.subUpdatesDesc')}> updateSetting({ subUpdates: v }))} /> diff --git a/frontend/src/schemas/setting.ts b/frontend/src/schemas/setting.ts index 4d3432ef4..56131721e 100644 --- a/frontend/src/schemas/setting.ts +++ b/frontend/src/schemas/setting.ts @@ -103,3 +103,7 @@ export const AllSettingSchema = z.object({ }).loose(); export type AllSettingInput = z.infer; + +export const FactoryDefaultsSchema = z.record(z.string(), z.string()); + +export type FactoryDefaults = z.infer; diff --git a/frontend/src/test/default-setting-tag.test.tsx b/frontend/src/test/default-setting-tag.test.tsx new file mode 100644 index 000000000..abe9229bc --- /dev/null +++ b/frontend/src/test/default-setting-tag.test.tsx @@ -0,0 +1,64 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { keys } from '@/api/queryKeys'; +import DefaultSettingTag, { matchesFactoryDefault } from '@/components/ui/DefaultSettingTag'; +import { makeTestQueryClient, renderWithProviders } from './test-utils'; + +function clientWithDefaults(defaults: Record) { + const queryClient = makeTestQueryClient(); + queryClient.setQueryData(keys.settings.factoryDefaults(), defaults); + return queryClient; +} + +describe('matchesFactoryDefault', () => { + it('compares by value with type-aware coercion', () => { + expect(matchesFactoryDefault(2096, '2096')).toBe(true); + expect(matchesFactoryDefault(8443, '2096')).toBe(false); + expect(matchesFactoryDefault(true, 'true')).toBe(true); + expect(matchesFactoryDefault(false, 'true')).toBe(false); + expect(matchesFactoryDefault('/sub/', '/sub/')).toBe(true); + expect(matchesFactoryDefault('/other/', '/sub/')).toBe(false); + }); + + it('never matches when the key has no shipped default', () => { + expect(matchesFactoryDefault(2096, undefined)).toBe(false); + }); + + it('rejects blank or unparsable defaults instead of coercing them', () => { + expect(matchesFactoryDefault(0, '')).toBe(false); + expect(matchesFactoryDefault(0, ' ')).toBe(false); + expect(matchesFactoryDefault(0, 'none')).toBe(false); + expect(matchesFactoryDefault(false, '')).toBe(false); + expect(matchesFactoryDefault(false, 'no')).toBe(false); + }); +}); + +describe('DefaultSettingTag', () => { + it('shows the tag when the current value equals the shipped default, however it got there', () => { + renderWithProviders( + , + { queryClient: clientWithDefaults({ subPort: '2096' }) }, + ); + + expect(screen.getByText('Default')).toBeDefined(); + }); + + it('renders nothing when the value differs from the default', () => { + renderWithProviders( + , + { queryClient: clientWithDefaults({ subPort: '2096' }) }, + ); + + expect(screen.queryByText('Default')).toBeNull(); + }); + + it('renders nothing while defaults are unknown', () => { + renderWithProviders( + , + { queryClient: makeTestQueryClient() }, + ); + + expect(screen.queryByText('Default')).toBeNull(); + }); +}); diff --git a/frontend/src/test/factory-defaults-contract.test.ts b/frontend/src/test/factory-defaults-contract.test.ts new file mode 100644 index 000000000..a001217d9 --- /dev/null +++ b/frontend/src/test/factory-defaults-contract.test.ts @@ -0,0 +1,53 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { matchesFactoryDefault } from '@/components/ui/DefaultSettingTag'; +import { AllSetting } from '@/models/setting'; + +/* + * Contract test between the three homes of a setting's default value: the Go + * defaultValueMap (authoritative, served by /setting/factoryDefaults), the + * frontend AllSetting class defaults (what the form shows when the API omits + * a value), and the tag's own comparison. If someone bumps a default on one + * side only, the Default tag would start calling a different value "Default" + * than the one the form displays — this test fails instead. + */ + +function goDefaultLiterals(): Record { + const source = readFileSync( + resolve(process.cwd(), '..', 'internal', 'web', 'service', 'setting.go'), + 'utf8', + ); + const start = source.indexOf('var defaultValueMap = map[string]string{'); + const end = source.indexOf('\n}', start); + const block = source.slice(start, end); + const literals: Record = {}; + for (const match of block.matchAll(/"([A-Za-z0-9]+)":\s+"((?:[^"\\]|\\.)*)"\s*,/g)) { + literals[match[1]] = JSON.parse(`"${match[2]}"`); + } + return literals; +} + +describe('factory defaults contract', () => { + const goDefaults = goDefaultLiterals(); + const frontend = new AllSetting() as unknown as Record; + const sharedKeys = Object.keys(goDefaults).filter((key) => { + const value = frontend[key]; + return typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string'; + }); + + it('parses a plausible slice of the Go map', () => { + expect(goDefaults.webPort).toBe('2053'); + expect(goDefaults.subPort).toBe('2096'); + expect(sharedKeys.length).toBeGreaterThan(20); + }); + + it.each(sharedKeys)('frontend default for %s matches the shipped default', (key) => { + expect( + matchesFactoryDefault(frontend[key], goDefaults[key]), + `AllSetting.${key} = ${JSON.stringify(frontend[key])} vs defaultValueMap ${JSON.stringify(goDefaults[key])}`, + ).toBe(true); + }); +}); diff --git a/frontend/src/test/test-utils.tsx b/frontend/src/test/test-utils.tsx index d0640eaa2..be81b5305 100644 --- a/frontend/src/test/test-utils.tsx +++ b/frontend/src/test/test-utils.tsx @@ -1,10 +1,20 @@ import type { ReactElement } from 'react'; import { render, fireEvent } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider } from '@/hooks/useTheme'; -export function renderWithProviders(ui: ReactElement) { - return render({ui}); +export function makeTestQueryClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +export function renderWithProviders(ui: ReactElement, options?: { queryClient?: QueryClient }) { + const queryClient = options?.queryClient ?? makeTestQueryClient(); + return render( + + {ui} + , + ); } export function fieldLabels(): string[] { diff --git a/internal/web/controller/setting.go b/internal/web/controller/setting.go index 82545a863..0516bceca 100644 --- a/internal/web/controller/setting.go +++ b/internal/web/controller/setting.go @@ -65,6 +65,7 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) { g.POST("/all", a.getAllSetting) g.POST("/defaultSettings", a.getDefaultSettings) + g.POST("/factoryDefaults", a.getFactoryDefaults) g.POST("/update", a.updateSetting) g.POST("/validateRegex", a.validateRegex) g.POST("/updateUser", a.updateUser) @@ -112,6 +113,10 @@ func (a *SettingController) getDefaultSettings(c *gin.Context) { jsonObj(c, result, nil) } +func (a *SettingController) getFactoryDefaults(c *gin.Context) { + jsonObj(c, a.settingService.GetFactoryDefaults(), nil) +} + // updateSetting updates all settings with the provided data. func (a *SettingController) updateSetting(c *gin.Context) { form, ok := middleware.BindAndValidate[updateSettingForm](c) diff --git a/internal/web/service/setting.go b/internal/web/service/setting.go index 39a731099..1ba6bdffb 100644 --- a/internal/web/service/setting.go +++ b/internal/web/service/setting.go @@ -1445,3 +1445,33 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) { return result, nil } + +var factoryDefaultSecretKeys = map[string]bool{ + "tgBotToken": true, + "twoFactorToken": true, + "ldapPassword": true, + "smtpPassword": true, +} + +/* +GetFactoryDefaults returns the shipped default value per setting, keyed by +the AllSetting json field name. Unlike GetDefaultSettings (which reports +current effective values), this is defaultValueMap projected through the +AllSetting field set: only keys that exist as an AllSetting json tag are +returned, minus the credential fields in factoryDefaultSecretKeys. Keys +with no AllSetting field (secret, panelGuid, the node mTLS material, +xrayTemplateConfig) are excluded structurally rather than by deny-list. +*/ +func (s *SettingService) GetFactoryDefaults() map[string]string { + result := make(map[string]string) + for _, field := range reflect_util.GetFields(reflect.TypeFor[entity.AllSetting]()) { + key := field.Tag.Get("json") + if key == "" || factoryDefaultSecretKeys[key] { + continue + } + if value, ok := defaultValueMap[key]; ok { + result[key] = value + } + } + return result +} diff --git a/internal/web/service/setting_factory_defaults_test.go b/internal/web/service/setting_factory_defaults_test.go new file mode 100644 index 000000000..73c9f4bad --- /dev/null +++ b/internal/web/service/setting_factory_defaults_test.go @@ -0,0 +1,83 @@ +package service + +import ( + "reflect" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/util/reflect_util" + "github.com/mhsanaei/3x-ui/v3/internal/web/entity" +) + +func allSettingJSONTags(t *testing.T) map[string]bool { + t.Helper() + tags := make(map[string]bool) + for _, field := range reflect_util.GetFields(reflect.TypeFor[entity.AllSetting]()) { + if tag := field.Tag.Get("json"); tag != "" { + tags[tag] = true + } + } + return tags +} + +func TestGetFactoryDefaultsExposesBrowserSafeKeys(t *testing.T) { + defaults := (&SettingService{}).GetFactoryDefaults() + + tests := []struct { + key string + want string + }{ + {key: "webPort", want: "2053"}, + {key: "subPort", want: "2096"}, + } + for _, tc := range tests { + t.Run(tc.key, func(t *testing.T) { + got, ok := defaults[tc.key] + if !ok { + t.Fatalf("expected key %q in factory defaults", tc.key) + } + if got != tc.want { + t.Errorf("factory default for %q = %q, want %q", tc.key, got, tc.want) + } + }) + } +} + +func TestGetFactoryDefaultsOmitsSensitiveMaterial(t *testing.T) { + defaults := (&SettingService{}).GetFactoryDefaults() + + for _, key := range []string{ + "secret", + "panelGuid", + "nodeMtlsCaCertPem", + "nodeMtlsCaKeyPem", + "nodeMtlsClientCertPem", + "nodeMtlsClientKeyPem", + "xrayTemplateConfig", + "tgBotToken", + "twoFactorToken", + "ldapPassword", + "smtpPassword", + } { + t.Run(key, func(t *testing.T) { + if _, ok := defaults[key]; ok { + t.Errorf("factory defaults must not expose %q", key) + } + }) + } +} + +func TestGetFactoryDefaultsInvariant(t *testing.T) { + defaults := (&SettingService{}).GetFactoryDefaults() + tags := allSettingJSONTags(t) + + for key := range defaults { + t.Run(key, func(t *testing.T) { + if !tags[key] { + t.Errorf("key %q is not an entity.AllSetting json tag", key) + } + if factoryDefaultSecretKeys[key] { + t.Errorf("key %q is in the credential deny-list and must not be returned", key) + } + }) + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 3f4b97e69..1babe9b3c 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -998,6 +998,7 @@ "pinFetchFailed": "تعذّر جلب الشهادة" }, "settings": { + "defaultTag": "افتراضي", "title": "إعدادات البانل", "save": "حفظ", "infoDesc": "كل تغيير هتعمله هنا لازم يتخزن. ياريت تعيد تشغيل البانل عشان التعديلات تتفعل.", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 539a863d7..1e733cced 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -1115,6 +1115,7 @@ "pinFetchFailed": "Could not fetch the certificate" }, "settings": { + "defaultTag": "Default", "title": "Panel Settings", "save": "Save", "infoDesc": "Every change made here needs to be saved. Please restart the panel to apply changes.", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 3451a2ac3..a37afdef9 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -998,6 +998,7 @@ "pinFetchFailed": "No se pudo obtener el certificado" }, "settings": { + "defaultTag": "Predeterminado", "title": "Configuraciones", "save": "Guardar", "infoDesc": "Cada cambio realizado aquí debe ser guardado. Por favor, reinicie el panel para aplicar los cambios.", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 8fea8746b..a72f506cd 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -998,6 +998,7 @@ "pinFetchFailed": "دریافت گواهی ممکن نشد" }, "settings": { + "defaultTag": "پیش‌فرض", "title": "تنظیمات پنل", "save": "ذخیره", "infoDesc": "برای اعمال تغییرات در این بخش باید پس از ذخیره کردن، پنل را ریستارت کنید", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index d6cfa5ba4..11b3ada60 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -998,6 +998,7 @@ "pinFetchFailed": "Tidak dapat mengambil sertifikat" }, "settings": { + "defaultTag": "Bawaan", "title": "Pengaturan Panel", "save": "Simpan", "infoDesc": "Setiap perubahan yang dibuat di sini perlu disimpan. Harap restart panel untuk menerapkan perubahan.", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index b941df361..1d0548e71 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -998,6 +998,7 @@ "pinFetchFailed": "証明書を取得できませんでした" }, "settings": { + "defaultTag": "デフォルト", "title": "パネル設定", "save": "保存", "infoDesc": "ここでのすべての変更は、保存してパネルを再起動する必要があります", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index e5bdfe499..e51a7b126 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -998,6 +998,7 @@ "pinFetchFailed": "Não foi possível obter o certificado" }, "settings": { + "defaultTag": "Padrão", "title": "Configurações do Painel", "save": "Salvar", "infoDesc": "Toda alteração feita aqui precisa ser salva. Reinicie o painel para aplicar as alterações.", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index bead99939..b9bf73195 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -998,6 +998,7 @@ "pinFetchFailed": "Не удалось получить сертификат" }, "settings": { + "defaultTag": "По умолчанию", "title": "Настройки", "save": "Сохранить", "infoDesc": "Сохраните изменения и перезапустите панель для их применения.", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index bc3b16fef..d54ba80dd 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -998,6 +998,7 @@ "pinFetchFailed": "Sertifika alınamadı" }, "settings": { + "defaultTag": "Varsayılan", "title": "Panel Ayarları", "save": "Kaydet", "infoDesc": "Burada yapılan her değişikliğin kaydedilmesi gerekir. Değişikliklerin uygulanması için paneli yeniden başlatın.", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index e8eeb9aec..2ff8e3f05 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -998,6 +998,7 @@ "pinFetchFailed": "Не вдалося отримати сертифікат" }, "settings": { + "defaultTag": "Типово", "title": "Параметри панелі", "save": "Зберегти", "infoDesc": "Кожна внесена тут зміна повинна бути збережена. Перезапустіть панель, щоб застосувати зміни.", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 3f8352d68..fd87a1ada 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -998,6 +998,7 @@ "pinFetchFailed": "Không thể lấy chứng chỉ" }, "settings": { + "defaultTag": "Mặc định", "title": "Cài đặt", "save": "Lưu", "infoDesc": "Mọi thay đổi được thực hiện ở đây cần phải được lưu. Vui lòng khởi động lại bảng điều khiển để áp dụng các thay đổi.", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 6116b3579..904c330da 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -998,6 +998,7 @@ "pinFetchFailed": "无法获取证书" }, "settings": { + "defaultTag": "默认", "title": "面板设置", "save": "保存", "infoDesc": "此处的所有更改都需要保存并重启面板才能生效", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 15452f638..52f225143 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -998,6 +998,7 @@ "pinFetchFailed": "無法取得憑證" }, "settings": { + "defaultTag": "預設", "title": "面板設定", "save": "儲存", "infoDesc": "此處的所有更改都需要儲存並重啟面板才能生效",