diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 8d1275b28..27d16b307 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -138,6 +138,46 @@ "minimum": 1, "type": "integer" }, + "smtpCpu": { + "description": "CPU threshold for email notifications", + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "smtpEnable": { + "description": "Email (SMTP) notification settings\nEnable email notifications", + "type": "boolean" + }, + "smtpEnabledEvents": { + "description": "Comma-separated event types to send via email", + "type": "string" + }, + "smtpEncryptionType": { + "description": "SMTP encryption: none, starttls, tls", + "type": "string" + }, + "smtpHost": { + "description": "SMTP server host", + "type": "string" + }, + "smtpPassword": { + "description": "SMTP password", + "type": "string" + }, + "smtpPort": { + "description": "SMTP server port", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "smtpTo": { + "description": "Comma-separated recipient emails", + "type": "string" + }, + "smtpUsername": { + "description": "SMTP username", + "type": "string" + }, "subAnnounce": { "description": "Subscription announce", "type": "string" @@ -277,10 +317,6 @@ "description": "Telegram bot settings\nEnable Telegram bot notifications", "type": "boolean" }, - "tgBotLoginNotify": { - "description": "Send login notifications", - "type": "boolean" - }, "tgBotProxy": { "description": "Proxy URL for Telegram bot", "type": "string" @@ -295,6 +331,10 @@ "minimum": 0, "type": "integer" }, + "tgEnabledEvents": { + "description": "Comma-separated event types to send via Telegram", + "type": "string" + }, "tgLang": { "description": "Telegram bot language", "type": "string" @@ -387,6 +427,15 @@ "remarkModel", "restartXrayOnClientDisable", "sessionMaxAge", + "smtpCpu", + "smtpEnable", + "smtpEnabledEvents", + "smtpEncryptionType", + "smtpHost", + "smtpPassword", + "smtpPort", + "smtpTo", + "smtpUsername", "subAnnounce", "subCertFile", "subClashEnable", @@ -421,10 +470,10 @@ "tgBotBackup", "tgBotChatId", "tgBotEnable", - "tgBotLoginNotify", "tgBotProxy", "tgBotToken", "tgCpu", + "tgEnabledEvents", "tgLang", "tgRunTime", "timeLocation", @@ -471,6 +520,9 @@ "hasNordSecret": { "type": "boolean" }, + "hasSmtpPassword": { + "type": "boolean" + }, "hasTgBotToken": { "type": "boolean" }, @@ -572,6 +624,46 @@ "minimum": 1, "type": "integer" }, + "smtpCpu": { + "description": "CPU threshold for email notifications", + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "smtpEnable": { + "description": "Email (SMTP) notification settings\nEnable email notifications", + "type": "boolean" + }, + "smtpEnabledEvents": { + "description": "Comma-separated event types to send via email", + "type": "string" + }, + "smtpEncryptionType": { + "description": "SMTP encryption: none, starttls, tls", + "type": "string" + }, + "smtpHost": { + "description": "SMTP server host", + "type": "string" + }, + "smtpPassword": { + "description": "SMTP password", + "type": "string" + }, + "smtpPort": { + "description": "SMTP server port", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "smtpTo": { + "description": "Comma-separated recipient emails", + "type": "string" + }, + "smtpUsername": { + "description": "SMTP username", + "type": "string" + }, "subAnnounce": { "description": "Subscription announce", "type": "string" @@ -711,10 +803,6 @@ "description": "Telegram bot settings\nEnable Telegram bot notifications", "type": "boolean" }, - "tgBotLoginNotify": { - "description": "Send login notifications", - "type": "boolean" - }, "tgBotProxy": { "description": "Proxy URL for Telegram bot", "type": "string" @@ -729,6 +817,10 @@ "minimum": 0, "type": "integer" }, + "tgEnabledEvents": { + "description": "Comma-separated event types to send via Telegram", + "type": "string" + }, "tgLang": { "description": "Telegram bot language", "type": "string" @@ -799,6 +891,7 @@ "hasApiToken", "hasLdapPassword", "hasNordSecret", + "hasSmtpPassword", "hasTgBotToken", "hasTwoFactorToken", "hasWarpSecret", @@ -827,6 +920,15 @@ "remarkModel", "restartXrayOnClientDisable", "sessionMaxAge", + "smtpCpu", + "smtpEnable", + "smtpEnabledEvents", + "smtpEncryptionType", + "smtpHost", + "smtpPassword", + "smtpPort", + "smtpTo", + "smtpUsername", "subAnnounce", "subCertFile", "subClashEnable", @@ -861,10 +963,10 @@ "tgBotBackup", "tgBotChatId", "tgBotEnable", - "tgBotLoginNotify", "tgBotProxy", "tgBotToken", "tgCpu", + "tgEnabledEvents", "tgLang", "tgRunTime", "timeLocation", @@ -7009,6 +7111,75 @@ } } }, + "/panel/api/setting/testSmtp": { + "post": { + "tags": [ + "Settings" + ], + "summary": "Test SMTP connection with stage-by-stage reporting (connect, auth, send). Returns structured result with stage and message.", + "operationId": "post_panel_api_setting_testSmtp", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + }, + "example": { + "success": true, + "stage": "send", + "msg": "Test email sent successfully" + } + } + } + } + } + } + }, + "/panel/api/setting/testTgBot": { + "post": { + "tags": [ + "Settings" + ], + "summary": "Test Telegram bot connection by sending a test message to the configured chat.", + "operationId": "post_panel_api_setting_testTgBot", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + }, + "example": { + "success": true, + "msg": "Test message sent to Telegram" + } + } + } + } + } + } + }, "/panel/api/setting/getDefaultJsonConfig": { "get": { "tags": [ diff --git a/frontend/src/components/ui/EventBusCheckboxes.tsx b/frontend/src/components/ui/EventBusCheckboxes.tsx new file mode 100644 index 000000000..7b769b2fd --- /dev/null +++ b/frontend/src/components/ui/EventBusCheckboxes.tsx @@ -0,0 +1,147 @@ +import { Checkbox, Collapse, InputNumber, Space } from 'antd'; +import { DownOutlined, RightOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; + +interface EventGroup { + key: string; + labelKey: string; + events: { value: string; labelKey: string }[]; +} + +const EVENT_GROUPS: EventGroup[] = [ + { + key: 'outbound', + labelKey: 'pages.settings.eventGroupOutbound', + events: [ + { value: 'outbound.down', labelKey: 'pages.settings.eventOutboundDown' }, + { value: 'outbound.up', labelKey: 'pages.settings.eventOutboundUp' }, + ], + }, + { + key: 'xray', + labelKey: 'pages.settings.eventGroupXray', + events: [ + { value: 'xray.crash', labelKey: 'pages.settings.eventXrayCrash' }, + ], + }, + { + key: 'node', + labelKey: 'pages.settings.eventGroupNode', + events: [ + { value: 'node.down', labelKey: 'pages.settings.eventNodeDown' }, + { value: 'node.up', labelKey: 'pages.settings.eventNodeUp' }, + ], + }, + { + key: 'system', + labelKey: 'pages.settings.eventGroupSystem', + events: [ + { value: 'cpu.high', labelKey: 'pages.settings.eventCPUHigh' }, + ], + }, + { + key: 'security', + labelKey: 'pages.settings.eventGroupSecurity', + events: [ + { value: 'login.attempt', labelKey: 'pages.settings.eventLoginAttempt' }, + ], + }, +]; + +interface EventBusCheckboxesProps { + value: string; + onChange: (v: string) => void; + /** Maps event value → { key: setting field name, value: current value } for inline inputs */ + extra?: Record; + /** Callback when extra input changes: (settingKey, newValue) => void */ + onExtraChange?: (key: string, v: number | null) => void; +} + +export function EventBusCheckboxes({ value, onChange, extra, onExtraChange }: EventBusCheckboxesProps) { + const { t } = useTranslation(); + const selected = value ? value.split(',').map((s) => s.trim()).filter(Boolean) : []; + + function toggle(eventType: string) { + const next = selected.includes(eventType) + ? selected.filter((e) => e !== eventType) + : [...selected, eventType]; + onChange(next.join(',')); + } + + function toggleGroup(group: EventGroup) { + const groupValues = group.events.map((e) => e.value); + const allSelected = groupValues.every((v) => selected.includes(v)); + let next: string[]; + if (allSelected) { + next = selected.filter((v) => !groupValues.includes(v)); + } else { + next = [...new Set([...selected, ...groupValues])]; + } + onChange(next.join(',')); + } + + const items = EVENT_GROUPS.map((group) => { + const count = group.events.filter((e) => selected.includes(e.value)).length; + const total = group.events.length; + const allSelected = count === total; + + return { + key: group.key, + label: ( +
+ {t(group.labelKey)} + + {count}/{total} + + 0 && count < total} + onClick={(e) => e.stopPropagation()} + onChange={() => toggleGroup(group)} + /> +
+ ), + children: ( + + + {group.events.map((et) => { + const checked = selected.includes(et.value); + const extraConf = extra?.[et.value]; + return ( + + toggle(et.value)}> + {t(et.labelKey)} + + {extraConf && onExtraChange && ( + onExtraChange(extraConf.key, v)} + style={{ width: 60 }} + /> + )} + + ); + })} + + + ), + }; + }); + + const defaultActiveKeys = EVENT_GROUPS + .filter((g) => g.events.some((e) => selected.includes(e.value))) + .map((g) => g.key); + + return ( + 0 ? defaultActiveKeys : ['outbound']} + expandIcon={({ isActive }) => isActive ? : } + size="small" + /> + ); +} diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 1e8121b57..40c08b27b 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 { EventBusCheckboxes } from './EventBusCheckboxes'; diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 59b9f783d..949d925bf 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -30,6 +30,15 @@ export const EXAMPLES: Record = { "remarkModel": "", "restartXrayOnClientDisable": false, "sessionMaxAge": 1, + "smtpCpu": 0, + "smtpEnable": false, + "smtpEnabledEvents": "", + "smtpEncryptionType": "", + "smtpHost": "", + "smtpPassword": "", + "smtpPort": 1, + "smtpTo": "", + "smtpUsername": "", "subAnnounce": "", "subCertFile": "", "subClashEnable": false, @@ -64,10 +73,10 @@ export const EXAMPLES: Record = { "tgBotBackup": false, "tgBotChatId": "", "tgBotEnable": false, - "tgBotLoginNotify": false, "tgBotProxy": "", "tgBotToken": "", "tgCpu": 0, + "tgEnabledEvents": "", "tgLang": "", "tgRunTime": "", "timeLocation": "", @@ -91,6 +100,7 @@ export const EXAMPLES: Record = { "hasApiToken": false, "hasLdapPassword": false, "hasNordSecret": false, + "hasSmtpPassword": false, "hasTgBotToken": false, "hasTwoFactorToken": false, "hasWarpSecret": false, @@ -119,6 +129,15 @@ export const EXAMPLES: Record = { "remarkModel": "", "restartXrayOnClientDisable": false, "sessionMaxAge": 1, + "smtpCpu": 0, + "smtpEnable": false, + "smtpEnabledEvents": "", + "smtpEncryptionType": "", + "smtpHost": "", + "smtpPassword": "", + "smtpPort": 1, + "smtpTo": "", + "smtpUsername": "", "subAnnounce": "", "subCertFile": "", "subClashEnable": false, @@ -153,10 +172,10 @@ export const EXAMPLES: Record = { "tgBotBackup": false, "tgBotChatId": "", "tgBotEnable": false, - "tgBotLoginNotify": false, "tgBotProxy": "", "tgBotToken": "", "tgCpu": 0, + "tgEnabledEvents": "", "tgLang": "", "tgRunTime": "", "timeLocation": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 9a524fac1..d3df09f1d 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -112,6 +112,46 @@ export const SCHEMAS: Record = { "minimum": 1, "type": "integer" }, + "smtpCpu": { + "description": "CPU threshold for email notifications", + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "smtpEnable": { + "description": "Email (SMTP) notification settings\nEnable email notifications", + "type": "boolean" + }, + "smtpEnabledEvents": { + "description": "Comma-separated event types to send via email", + "type": "string" + }, + "smtpEncryptionType": { + "description": "SMTP encryption: none, starttls, tls", + "type": "string" + }, + "smtpHost": { + "description": "SMTP server host", + "type": "string" + }, + "smtpPassword": { + "description": "SMTP password", + "type": "string" + }, + "smtpPort": { + "description": "SMTP server port", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "smtpTo": { + "description": "Comma-separated recipient emails", + "type": "string" + }, + "smtpUsername": { + "description": "SMTP username", + "type": "string" + }, "subAnnounce": { "description": "Subscription announce", "type": "string" @@ -251,10 +291,6 @@ export const SCHEMAS: Record = { "description": "Telegram bot settings\nEnable Telegram bot notifications", "type": "boolean" }, - "tgBotLoginNotify": { - "description": "Send login notifications", - "type": "boolean" - }, "tgBotProxy": { "description": "Proxy URL for Telegram bot", "type": "string" @@ -269,6 +305,10 @@ export const SCHEMAS: Record = { "minimum": 0, "type": "integer" }, + "tgEnabledEvents": { + "description": "Comma-separated event types to send via Telegram", + "type": "string" + }, "tgLang": { "description": "Telegram bot language", "type": "string" @@ -361,6 +401,15 @@ export const SCHEMAS: Record = { "remarkModel", "restartXrayOnClientDisable", "sessionMaxAge", + "smtpCpu", + "smtpEnable", + "smtpEnabledEvents", + "smtpEncryptionType", + "smtpHost", + "smtpPassword", + "smtpPort", + "smtpTo", + "smtpUsername", "subAnnounce", "subCertFile", "subClashEnable", @@ -395,10 +444,10 @@ export const SCHEMAS: Record = { "tgBotBackup", "tgBotChatId", "tgBotEnable", - "tgBotLoginNotify", "tgBotProxy", "tgBotToken", "tgCpu", + "tgEnabledEvents", "tgLang", "tgRunTime", "timeLocation", @@ -445,6 +494,9 @@ export const SCHEMAS: Record = { "hasNordSecret": { "type": "boolean" }, + "hasSmtpPassword": { + "type": "boolean" + }, "hasTgBotToken": { "type": "boolean" }, @@ -546,6 +598,46 @@ export const SCHEMAS: Record = { "minimum": 1, "type": "integer" }, + "smtpCpu": { + "description": "CPU threshold for email notifications", + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "smtpEnable": { + "description": "Email (SMTP) notification settings\nEnable email notifications", + "type": "boolean" + }, + "smtpEnabledEvents": { + "description": "Comma-separated event types to send via email", + "type": "string" + }, + "smtpEncryptionType": { + "description": "SMTP encryption: none, starttls, tls", + "type": "string" + }, + "smtpHost": { + "description": "SMTP server host", + "type": "string" + }, + "smtpPassword": { + "description": "SMTP password", + "type": "string" + }, + "smtpPort": { + "description": "SMTP server port", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "smtpTo": { + "description": "Comma-separated recipient emails", + "type": "string" + }, + "smtpUsername": { + "description": "SMTP username", + "type": "string" + }, "subAnnounce": { "description": "Subscription announce", "type": "string" @@ -685,10 +777,6 @@ export const SCHEMAS: Record = { "description": "Telegram bot settings\nEnable Telegram bot notifications", "type": "boolean" }, - "tgBotLoginNotify": { - "description": "Send login notifications", - "type": "boolean" - }, "tgBotProxy": { "description": "Proxy URL for Telegram bot", "type": "string" @@ -703,6 +791,10 @@ export const SCHEMAS: Record = { "minimum": 0, "type": "integer" }, + "tgEnabledEvents": { + "description": "Comma-separated event types to send via Telegram", + "type": "string" + }, "tgLang": { "description": "Telegram bot language", "type": "string" @@ -773,6 +865,7 @@ export const SCHEMAS: Record = { "hasApiToken", "hasLdapPassword", "hasNordSecret", + "hasSmtpPassword", "hasTgBotToken", "hasTwoFactorToken", "hasWarpSecret", @@ -801,6 +894,15 @@ export const SCHEMAS: Record = { "remarkModel", "restartXrayOnClientDisable", "sessionMaxAge", + "smtpCpu", + "smtpEnable", + "smtpEnabledEvents", + "smtpEncryptionType", + "smtpHost", + "smtpPassword", + "smtpPort", + "smtpTo", + "smtpUsername", "subAnnounce", "subCertFile", "subClashEnable", @@ -835,10 +937,10 @@ export const SCHEMAS: Record = { "tgBotBackup", "tgBotChatId", "tgBotEnable", - "tgBotLoginNotify", "tgBotProxy", "tgBotToken", "tgCpu", + "tgEnabledEvents", "tgLang", "tgRunTime", "timeLocation", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 884a4d5dc..643e83f3d 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -35,6 +35,15 @@ export interface AllSetting { remarkModel: string; restartXrayOnClientDisable: boolean; sessionMaxAge: number; + smtpCpu: number; + smtpEnable: boolean; + smtpEnabledEvents: string; + smtpEncryptionType: string; + smtpHost: string; + smtpPassword: string; + smtpPort: number; + smtpTo: string; + smtpUsername: string; subAnnounce: string; subCertFile: string; subClashEnable: boolean; @@ -69,10 +78,10 @@ export interface AllSetting { tgBotBackup: boolean; tgBotChatId: string; tgBotEnable: boolean; - tgBotLoginNotify: boolean; tgBotProxy: string; tgBotToken: string; tgCpu: number; + tgEnabledEvents: string; tgLang: string; tgRunTime: string; timeLocation: string; @@ -97,6 +106,7 @@ export interface AllSettingView { hasApiToken: boolean; hasLdapPassword: boolean; hasNordSecret: boolean; + hasSmtpPassword: boolean; hasTgBotToken: boolean; hasTwoFactorToken: boolean; hasWarpSecret: boolean; @@ -125,6 +135,15 @@ export interface AllSettingView { remarkModel: string; restartXrayOnClientDisable: boolean; sessionMaxAge: number; + smtpCpu: number; + smtpEnable: boolean; + smtpEnabledEvents: string; + smtpEncryptionType: string; + smtpHost: string; + smtpPassword: string; + smtpPort: number; + smtpTo: string; + smtpUsername: string; subAnnounce: string; subCertFile: string; subClashEnable: boolean; @@ -159,10 +178,10 @@ export interface AllSettingView { tgBotBackup: boolean; tgBotChatId: string; tgBotEnable: boolean; - tgBotLoginNotify: boolean; tgBotProxy: string; tgBotToken: string; tgCpu: number; + tgEnabledEvents: string; tgLang: string; tgRunTime: string; timeLocation: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index cfdc086f1..e2ced9ad7 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -45,6 +45,15 @@ export const AllSettingSchema = z.object({ remarkModel: z.string(), restartXrayOnClientDisable: z.boolean(), sessionMaxAge: z.number().int().min(1).max(525600), + smtpCpu: z.number().int().min(0).max(100), + smtpEnable: z.boolean(), + smtpEnabledEvents: z.string(), + smtpEncryptionType: z.string(), + smtpHost: z.string(), + smtpPassword: z.string(), + smtpPort: z.number().int().min(1).max(65535), + smtpTo: z.string(), + smtpUsername: z.string(), subAnnounce: z.string(), subCertFile: z.string(), subClashEnable: z.boolean(), @@ -79,10 +88,10 @@ export const AllSettingSchema = z.object({ tgBotBackup: z.boolean(), tgBotChatId: z.string(), tgBotEnable: z.boolean(), - tgBotLoginNotify: z.boolean(), tgBotProxy: z.string(), tgBotToken: z.string(), tgCpu: z.number().int().min(0).max(100), + tgEnabledEvents: z.string(), tgLang: z.string(), tgRunTime: z.string(), timeLocation: z.string(), @@ -108,6 +117,7 @@ export const AllSettingViewSchema = z.object({ hasApiToken: z.boolean(), hasLdapPassword: z.boolean(), hasNordSecret: z.boolean(), + hasSmtpPassword: z.boolean(), hasTgBotToken: z.boolean(), hasTwoFactorToken: z.boolean(), hasWarpSecret: z.boolean(), @@ -136,6 +146,15 @@ export const AllSettingViewSchema = z.object({ remarkModel: z.string(), restartXrayOnClientDisable: z.boolean(), sessionMaxAge: z.number().int().min(1).max(525600), + smtpCpu: z.number().int().min(0).max(100), + smtpEnable: z.boolean(), + smtpEnabledEvents: z.string(), + smtpEncryptionType: z.string(), + smtpHost: z.string(), + smtpPassword: z.string(), + smtpPort: z.number().int().min(1).max(65535), + smtpTo: z.string(), + smtpUsername: z.string(), subAnnounce: z.string(), subCertFile: z.string(), subClashEnable: z.boolean(), @@ -170,10 +189,10 @@ export const AllSettingViewSchema = z.object({ tgBotBackup: z.boolean(), tgBotChatId: z.string(), tgBotEnable: z.boolean(), - tgBotLoginNotify: z.boolean(), tgBotProxy: z.string(), tgBotToken: z.string(), tgCpu: z.number().int().min(0).max(100), + tgEnabledEvents: z.string(), tgLang: z.string(), tgRunTime: z.string(), timeLocation: z.string(), diff --git a/frontend/src/layouts/AppSidebar.tsx b/frontend/src/layouts/AppSidebar.tsx index 35c7864f8..879b9df16 100644 --- a/frontend/src/layouts/AppSidebar.tsx +++ b/frontend/src/layouts/AppSidebar.tsx @@ -16,6 +16,7 @@ import { HeartOutlined, ImportOutlined, LogoutOutlined, + MailOutlined, MenuOutlined, MessageOutlined, MoonFilled, @@ -153,6 +154,7 @@ export default function AppSidebar() { { key: '/settings#general', icon: , label: t('pages.settings.panelSettings') }, { key: '/settings#security', icon: , label: t('pages.settings.securitySettings') }, { key: '/settings#telegram', icon: , label: t('pages.settings.TGBotSettings') }, + { key: '/settings#email', icon: , label: t('pages.settings.emailSettings') }, { key: '/settings#subscription', icon: , label: t('pages.settings.subSettings') }, ]; if (showSubFormats) { diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts index 5ccfc8756..f6c51e97c 100644 --- a/frontend/src/models/setting.ts +++ b/frontend/src/models/setting.ts @@ -17,12 +17,10 @@ export class AllSetting { datepicker: 'gregorian' | 'jalalian' = 'gregorian'; tgBotEnable = false; tgBotToken = ''; - tgBotProxy = ''; tgBotAPIServer = ''; tgBotChatId = ''; tgRunTime = '@daily'; tgBotBackup = false; - tgBotLoginNotify = true; tgCpu = 80; tgLang = 'en-US'; twoFactorEnable = false; @@ -84,12 +82,23 @@ export class AllSetting { ldapDefaultTotalGB = 0; ldapDefaultExpiryDays = 0; ldapDefaultLimitIP = 0; + tgEnabledEvents = ''; + smtpEnable = false; + smtpHost = ''; + smtpPort = 587; + smtpUsername = ''; + smtpPassword = ''; + smtpTo = ''; + smtpEncryptionType = 'starttls'; + smtpEnabledEvents = ''; + smtpCpu = 80; hasTgBotToken = false; hasTwoFactorToken = false; hasLdapPassword = false; hasApiToken = false; hasWarpSecret = false; hasNordSecret = false; + hasSmtpPassword = false; constructor(data?: unknown) { if (data != null) { diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 0da6e4676..32b14f32c 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -942,6 +942,18 @@ export const sections: readonly Section[] = [ path: '/panel/api/setting/restartPanel', summary: 'Restart the entire 3x-ui process after a 3-second grace period. The connection drops immediately; the panel comes back online ~5-10 seconds later.', }, + { + method: 'POST', + path: '/panel/api/setting/testSmtp', + summary: 'Test SMTP connection with stage-by-stage reporting (connect, auth, send). Returns structured result with stage and message.', + response: '{\n "success": true,\n "stage": "send",\n "msg": "Test email sent successfully"\n}', + }, + { + method: 'POST', + path: '/panel/api/setting/testTgBot', + summary: 'Test Telegram bot connection by sending a test message to the configured chat.', + response: '{\n "success": true,\n "msg": "Test message sent to Telegram"\n}', + }, { method: 'GET', path: '/panel/api/setting/getDefaultJsonConfig', diff --git a/frontend/src/pages/settings/EmailTab.tsx b/frontend/src/pages/settings/EmailTab.tsx new file mode 100644 index 000000000..5963b46a7 --- /dev/null +++ b/frontend/src/pages/settings/EmailTab.tsx @@ -0,0 +1,137 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd'; +import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons'; +import { HttpUtil } from '@/utils'; +import type { AllSetting } from '@/models/setting'; +import { SettingListItem, EventBusCheckboxes } from '@/components/ui'; +import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { catTabLabel } from './catTabLabel'; + +interface EmailTabProps { + allSetting: AllSetting; + updateSetting: (patch: Partial) => void; +} + +interface SmtpTestResult { + success: boolean; + stage?: string; + msg: string; +} + +export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) { + const { t } = useTranslation(); + const { isMobile } = useMediaQuery(); + const [testLoading, setTestLoading] = useState(false); + const [testResult, setTestResult] = useState(null); + + const stageLabel: Record = { + connect: t('pages.settings.smtpStageConnect'), + auth: t('pages.settings.smtpStageAuth'), + send: t('pages.settings.smtpStageSend'), + }; + + async function handleTestSmtp() { + setTestLoading(true); + setTestResult(null); + try { + const res = await HttpUtil.post('/panel/api/setting/testSmtp') as SmtpTestResult; + setTestResult(res); + } catch (e: unknown) { + setTestResult({ success: false, msg: e instanceof Error ? e.message : t('pages.settings.requestFailed') }); + } finally { + setTestLoading(false); + } + } + + return ( + , t('pages.settings.smtpSettings'), isMobile), + children: ( + <> + + updateSetting({ smtpEnable: v })} /> + + + + updateSetting({ smtpHost: e.target.value })} /> + + + + updateSetting({ smtpPort: Number(v) || 587 })} /> + + + + updateSetting({ smtpUsername: e.target.value })} /> + + + + updateSetting({ smtpPassword: e.target.value })} /> + + + + updateSetting({ smtpTo: e.target.value })} /> + + + + updateSetting({ tgBotAPIServer: e.target.value })} /> + + + + {testResult && ( + setTestResult(null)} + /> + )} + ), }, @@ -212,12 +243,14 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr updateSetting({ tgBotBackup: v })} /> - - updateSetting({ tgBotLoginNotify: v })} /> - - - updateSetting({ tgCpu: Number(v) || 0 })} /> + + + updateSetting({ tgEnabledEvents: v })} + extra={{ 'cpu.high': { key: 'tgCpu', value: allSetting.tgCpu } }} + onExtraChange={(key, v) => updateSetting({ [key]: Number(v) || 0 })} + /> ), diff --git a/frontend/src/schemas/setting.ts b/frontend/src/schemas/setting.ts index c5baf4852..9f81284b9 100644 --- a/frontend/src/schemas/setting.ts +++ b/frontend/src/schemas/setting.ts @@ -26,7 +26,6 @@ export const AllSettingSchema = z.object({ tgBotChatId: z.string().optional(), tgRunTime: z.string().optional(), tgBotBackup: z.boolean().optional(), - tgBotLoginNotify: z.boolean().optional(), tgCpu: z.number().int().min(0).max(100).optional(), tgLang: z.string().optional(), twoFactorEnable: z.boolean().optional(), @@ -91,6 +90,7 @@ export const AllSettingSchema = z.object({ hasApiToken: z.boolean().optional(), hasWarpSecret: z.boolean().optional(), hasNordSecret: z.boolean().optional(), + hasSmtpPassword: z.boolean().optional(), }).loose(); export type AllSettingInput = z.infer; diff --git a/internal/eventbus/bus.go b/internal/eventbus/bus.go new file mode 100644 index 000000000..6a20669f0 --- /dev/null +++ b/internal/eventbus/bus.go @@ -0,0 +1,123 @@ +package eventbus + +import ( + "sync" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/logger" +) + +// DefaultBufferSize is the number of events the bus can hold before Publish starts dropping. +const DefaultBufferSize = 256 + +// subscriber pairs an ID with its event handler. +type subscriber struct { + id string + handler func(Event) +} + +// Bus is a minimal in-process pub/sub event bus backed by a buffered channel. +// Producers call Publish (non-blocking) and every event is fanned out to all +// subscribers; per-event filtering is the subscriber's responsibility. +type Bus struct { + ch chan Event + subs []subscriber + mu sync.RWMutex + done chan struct{} + wg sync.WaitGroup +} + +// New creates a Bus with the given buffer size. Use 0 for DefaultBufferSize. +func New(bufSize int) *Bus { + if bufSize <= 0 { + bufSize = DefaultBufferSize + } + b := &Bus{ + ch: make(chan Event, bufSize), + done: make(chan struct{}), + } + b.wg.Add(1) + go b.dispatch() + return b +} + +// Subscribe registers a handler that receives every published event. +// The id is used for Unsubscribe; it must be unique across active subscribers. +// Subscribing with an already-registered id replaces the previous handler. +func (b *Bus) Subscribe(id string, handler func(Event)) { + b.mu.Lock() + defer b.mu.Unlock() + for i, s := range b.subs { + if s.id == id { + b.subs[i].handler = handler + return + } + } + b.subs = append(b.subs, subscriber{id: id, handler: handler}) +} + +// Unsubscribe removes a subscriber by id. Safe to call with unknown id. +func (b *Bus) Unsubscribe(id string) { + b.mu.Lock() + defer b.mu.Unlock() + for i, s := range b.subs { + if s.id == id { + b.subs = append(b.subs[:i], b.subs[i+1:]...) + return + } + } +} + +// Publish sends an event to all subscribers. Non-blocking — if the buffer is +// full the event is dropped and a warning is logged. +func (b *Bus) Publish(e Event) { + if e.Timestamp.IsZero() { + e.Timestamp = time.Now() + } + select { + case b.ch <- e: + default: + logger.Warning("eventbus: buffer full, dropping event ", e.Type) + } +} + +// dispatch is the fan-out loop. It reads events from the channel and calls +// every subscriber's handler sequentially. Handlers run on the dispatch +// goroutine — they must not block. +func (b *Bus) dispatch() { + defer b.wg.Done() + for { + select { + case e, ok := <-b.ch: + if !ok { + return + } + b.mu.RLock() + subs := make([]subscriber, len(b.subs)) + copy(subs, b.subs) + b.mu.RUnlock() + for _, s := range subs { + safeCall(s.handler, e) + } + case <-b.done: + return + } + } +} + +// safeCall invokes handler with panic recovery. +func safeCall(fn func(Event), e Event) { + defer func() { + if r := recover(); r != nil { + logger.Errorf("eventbus: subscriber panicked on %s: %v", e.Type, r) + } + }() + fn(e) +} + +// Stop shuts down the bus: the dispatch goroutine exits, in-flight handlers +// finish, and any events still buffered may be dropped. Safe to call once. +func (b *Bus) Stop() { + close(b.done) + b.wg.Wait() +} diff --git a/internal/eventbus/bus_test.go b/internal/eventbus/bus_test.go new file mode 100644 index 000000000..f031000c6 --- /dev/null +++ b/internal/eventbus/bus_test.go @@ -0,0 +1,199 @@ +package eventbus + +import ( + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/op/go-logging" +) + +func TestMain(m *testing.M) { + logger.InitLogger(logging.ERROR) + m.Run() +} + +func TestBusPublishSubscribe(t *testing.T) { + b := New(16) + defer b.Stop() + + var received Event + var wg sync.WaitGroup + wg.Add(1) + + b.Subscribe("test", func(e Event) { + received = e + wg.Done() + }) + + b.Publish(Event{Type: EventOutboundDown, Source: "my-proxy"}) + + select { + case <-waitDone(&wg): + case <-time.After(time.Second): + t.Fatal("subscriber did not receive event") + } + + if received.Type != EventOutboundDown { + t.Errorf("got type %q, want %q", received.Type, EventOutboundDown) + } + if received.Source != "my-proxy" { + t.Errorf("got source %q, want %q", received.Source, "my-proxy") + } + if received.Timestamp.IsZero() { + t.Error("timestamp not set") + } +} + +func TestBusMultipleSubscribers(t *testing.T) { + b := New(16) + defer b.Stop() + + var count atomic.Int32 + var wg sync.WaitGroup + wg.Add(2) + + b.Subscribe("a", func(e Event) { + count.Add(1) + wg.Done() + }) + b.Subscribe("b", func(e Event) { + count.Add(1) + wg.Done() + }) + + b.Publish(Event{Type: EventXrayCrash}) + + select { + case <-waitDone(&wg): + case <-time.After(time.Second): + t.Fatal("subscribers did not receive event") + } + + if count.Load() != 2 { + t.Errorf("got %d calls, want 2", count.Load()) + } +} + +func TestBusUnsubscribe(t *testing.T) { + b := New(16) + defer b.Stop() + + var count atomic.Int32 + + b.Subscribe("test", func(e Event) { + count.Add(1) + }) + b.Unsubscribe("test") + + b.Publish(Event{Type: EventOutboundUp}) + time.Sleep(50 * time.Millisecond) + + if count.Load() != 0 { + t.Errorf("got %d calls after unsubscribe, want 0", count.Load()) + } +} + +func TestBusReplaceSubscriber(t *testing.T) { + b := New(16) + defer b.Stop() + + var last string + var wg sync.WaitGroup + wg.Add(1) + + b.Subscribe("test", func(e Event) { + last = "old" + }) + b.Subscribe("test", func(e Event) { + last = "new" + wg.Done() + }) + + b.Publish(Event{Type: EventOutboundDown}) + + select { + case <-waitDone(&wg): + case <-time.After(time.Second): + t.Fatal("subscriber did not receive event") + } + + if last != "new" { + t.Errorf("got %q, want %q", last, "new") + } +} + +func TestBusPanicRecovery(t *testing.T) { + b := New(16) + defer b.Stop() + + var wg sync.WaitGroup + wg.Add(1) + + b.Subscribe("panicker", func(e Event) { + panic("oops") + }) + b.Subscribe("after", func(e Event) { + wg.Done() + }) + + b.Publish(Event{Type: EventOutboundDown}) + + select { + case <-waitDone(&wg): + case <-time.After(time.Second): + t.Fatal("subscriber after panicker did not receive event") + } +} + +func TestBusBufferFull(t *testing.T) { + b := New(2) + defer b.Stop() + + b.Subscribe("slow", func(e Event) { + time.Sleep(100 * time.Millisecond) + }) + + b.Publish(Event{Type: EventOutboundDown}) + b.Publish(Event{Type: EventOutboundUp}) + b.Publish(Event{Type: EventXrayCrash}) + + time.Sleep(50 * time.Millisecond) +} + +func TestBusZeroTimestamp(t *testing.T) { + b := New(16) + defer b.Stop() + + var received Event + var wg sync.WaitGroup + wg.Add(1) + + b.Subscribe("test", func(e Event) { + received = e + wg.Done() + }) + + b.Publish(Event{Type: EventOutboundDown}) + + select { + case <-waitDone(&wg): + case <-time.After(time.Second): + t.Fatal("subscriber did not receive event") + } + + if received.Timestamp.IsZero() { + t.Error("timestamp should be set automatically") + } +} + +func waitDone(wg *sync.WaitGroup) <-chan struct{} { + ch := make(chan struct{}) + go func() { + wg.Wait() + close(ch) + }() + return ch +} diff --git a/internal/eventbus/events.go b/internal/eventbus/events.go new file mode 100644 index 000000000..fdd44d601 --- /dev/null +++ b/internal/eventbus/events.go @@ -0,0 +1,64 @@ +package eventbus + +import "time" + +// EventType identifies the kind of event flowing through the bus. +type EventType string + +const ( + // Outbound health (observatory-driven) + EventOutboundDown EventType = "outbound.down" + EventOutboundUp EventType = "outbound.up" + + // Xray core (local) + EventXrayCrash EventType = "xray.crash" + + // Node health (heartbeat-driven) + EventNodeDown EventType = "node.down" + EventNodeUp EventType = "node.up" + + // System health + EventCPUHigh EventType = "cpu.high" + + // Security + EventLoginAttempt EventType = "login.attempt" +) + +// Event is the unit of information flowing through the bus. +type Event struct { + Type EventType + Source string // outbound tag, node name, client email, IP, etc. + Data any // event-specific payload, may be nil + Timestamp time.Time // when the event was detected +} + +// OutboundHealthData carries observatory details for outbound events. +type OutboundHealthData struct { + Delay int64 // last measured delay in ms, 0 if unknown + Error string // last error if probe failed, empty if up +} + +// NodeHealthData carries heartbeat details for node events. +type NodeHealthData struct { + NodeId int + LatencyMs int + CpuPct float64 + MemPct float64 + XrayState string // "running", "stopped", etc. + XrayError string +} + +// LoginEventData carries login attempt details. +type LoginEventData struct { + Username string + IP string + Time string + Status string // "success" or "fail" + Reason string +} + +// SystemMetricData carries raw system metric values for threshold-based events. +type SystemMetricData struct { + Percent float64 // current usage percentage + Threshold int // configured threshold +} diff --git a/internal/eventbus/filter.go b/internal/eventbus/filter.go new file mode 100644 index 000000000..71ad253dc --- /dev/null +++ b/internal/eventbus/filter.go @@ -0,0 +1,33 @@ +package eventbus + +import ( + "sync" + "time" +) + +// RateLimiter prevents notification spam from flapping events. +type RateLimiter struct { + mu sync.Mutex + lastSent map[string]time.Time + cooldown time.Duration +} + +// NewRateLimiter creates a rate limiter with the given cooldown period. +func NewRateLimiter(cooldown time.Duration) *RateLimiter { + return &RateLimiter{ + lastSent: make(map[string]time.Time), + cooldown: cooldown, + } +} + +// Allow returns true if the event should be sent (cooldown has elapsed). +func (r *RateLimiter) Allow(eventType EventType, source string) bool { + key := string(eventType) + ":" + source + r.mu.Lock() + defer r.mu.Unlock() + if time.Since(r.lastSent[key]) < r.cooldown { + return false + } + r.lastSent[key] = time.Now() + return true +} diff --git a/internal/web/controller/setting.go b/internal/web/controller/setting.go index 1698fafac..1a85661c7 100644 --- a/internal/web/controller/setting.go +++ b/internal/web/controller/setting.go @@ -10,6 +10,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/web/entity" "github.com/mhsanaei/3x-ui/v3/internal/web/middleware" "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/web/service/email" "github.com/mhsanaei/3x-ui/v3/internal/web/service/panel" "github.com/mhsanaei/3x-ui/v3/internal/web/session" @@ -54,11 +55,14 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) { g.POST("/apiTokens/create", a.createApiToken) g.POST("/apiTokens/delete/:id", a.deleteApiToken) g.POST("/apiTokens/setEnabled/:id", a.setApiTokenEnabled) + g.POST("/testSmtp", a.testSmtp) + g.POST("/testTgBot", a.testTgBot) } -// getAllSetting retrieves all current settings. +// getAllSetting retrieves all current settings as the browser-safe view: +// secret values are redacted and surfaced as has* presence flags instead. func (a *SettingController) getAllSetting(c *gin.Context) { - allSetting, err := a.settingService.GetAllSetting() + allSetting, err := a.settingService.GetAllSettingView() if err != nil { jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err) return @@ -198,3 +202,58 @@ func (a *SettingController) setApiTokenEnabled(c *gin.Context) { } jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabled(id, form.Enabled)) } + +func (a *SettingController) testSmtp(c *gin.Context) { + if emailService == nil { + jsonMsg(c, I18nWeb(c, "pages.settings.smtpNotInitialized"), errors.New("email service not available")) + return + } + logger.Info("SMTP test: starting...") + result := emailService.TestConnection() + if !result.Success { + logger.Warning("SMTP test failed at", result.Stage+":", result.Message) + c.JSON(200, gin.H{ + "success": false, + "stage": result.Stage, + "msg": result.Message, + }) + return + } + logger.Info("SMTP test: success") + c.JSON(200, gin.H{ + "success": true, + "stage": result.Stage, + "msg": result.Message, + }) +} + +func (a *SettingController) testTgBot(c *gin.Context) { + enabled, err := a.settingService.GetTgbotEnabled() + if err != nil || !enabled { + jsonMsg(c, I18nWeb(c, "pages.settings.tgBotNotEnabled"), errors.New("telegram bot disabled")) + return + } + // Import tgbot package would create a circular dependency, so we call + // the test through the global function registered at startup. + if testTgFunc != nil { + if err := testTgFunc(); err != nil { + jsonMsg(c, I18nWeb(c, "pages.settings.tgTestFailed")+": "+err.Error(), err) + return + } + jsonMsg(c, I18nWeb(c, "pages.settings.tgTestSuccess"), nil) + return + } + jsonMsg(c, I18nWeb(c, "pages.settings.tgBotNotRunning"), errors.New("bot not started")) +} + +// testTgFunc is set from web layer to test Telegram sending without circular imports. +var testTgFunc func() error + +// SetTestTgFunc registers the function used to test Telegram sending. +func SetTestTgFunc(fn func() error) { testTgFunc = fn } + +// emailService is set from web layer. +var emailService *email.EmailService + +// SetEmailService registers the email service for test endpoints. +func SetEmailService(s *email.EmailService) { emailService = s } diff --git a/internal/web/entity/entity.go b/internal/web/entity/entity.go index bf235f07a..1b86dee54 100644 --- a/internal/web/entity/entity.go +++ b/internal/web/entity/entity.go @@ -39,16 +39,27 @@ type AllSetting struct { Datepicker string `json:"datepicker" form:"datepicker"` // Date picker format // Telegram bot settings - TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"` // Enable Telegram bot notifications - TgBotToken string `json:"tgBotToken" form:"tgBotToken"` // Telegram bot token - TgBotProxy string `json:"tgBotProxy" form:"tgBotProxy"` // Proxy URL for Telegram bot - TgBotAPIServer string `json:"tgBotAPIServer" form:"tgBotAPIServer"` // Custom API server for Telegram bot - TgBotChatId string `json:"tgBotChatId" form:"tgBotChatId"` // Telegram chat ID for notifications - TgRunTime string `json:"tgRunTime" form:"tgRunTime"` // Cron schedule for Telegram notifications - TgBotBackup bool `json:"tgBotBackup" form:"tgBotBackup"` // Enable database backup via Telegram - TgBotLoginNotify bool `json:"tgBotLoginNotify" form:"tgBotLoginNotify"` // Send login notifications - TgCpu int `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"` // CPU usage threshold for alerts (percent) - TgLang string `json:"tgLang" form:"tgLang"` // Telegram bot language + TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"` // Enable Telegram bot notifications + TgBotToken string `json:"tgBotToken" form:"tgBotToken"` // Telegram bot token + TgBotProxy string `json:"tgBotProxy" form:"tgBotProxy"` // Proxy URL for Telegram bot + TgBotAPIServer string `json:"tgBotAPIServer" form:"tgBotAPIServer"` // Custom API server for Telegram bot + TgBotChatId string `json:"tgBotChatId" form:"tgBotChatId"` // Telegram chat ID for notifications + TgRunTime string `json:"tgRunTime" form:"tgRunTime"` // Cron schedule for Telegram notifications + TgBotBackup bool `json:"tgBotBackup" form:"tgBotBackup"` // Enable database backup via Telegram + TgCpu int `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"` // CPU usage threshold for alerts (percent) + TgLang string `json:"tgLang" form:"tgLang"` // Telegram bot language + TgEnabledEvents string `json:"tgEnabledEvents" form:"tgEnabledEvents"` // Comma-separated event types to send via Telegram + + // Email (SMTP) notification settings + SmtpEnable bool `json:"smtpEnable" form:"smtpEnable"` // Enable email notifications + SmtpHost string `json:"smtpHost" form:"smtpHost"` // SMTP server host + SmtpPort int `json:"smtpPort" form:"smtpPort" validate:"gte=1,lte=65535"` // SMTP server port + SmtpUsername string `json:"smtpUsername" form:"smtpUsername"` // SMTP username + SmtpPassword string `json:"smtpPassword" form:"smtpPassword"` // SMTP password + SmtpTo string `json:"smtpTo" form:"smtpTo"` // Comma-separated recipient emails + SmtpEncryptionType string `json:"smtpEncryptionType" form:"smtpEncryptionType"` // SMTP encryption: none, starttls, tls + SmtpEnabledEvents string `json:"smtpEnabledEvents" form:"smtpEnabledEvents"` // Comma-separated event types to send via email + SmtpCpu int `json:"smtpCpu" form:"smtpCpu" validate:"gte=0,lte=100"` // CPU threshold for email notifications // Security settings TimeLocation string `json:"timeLocation" form:"timeLocation"` // Time zone location @@ -130,6 +141,7 @@ type AllSettingView struct { HasApiToken bool `json:"hasApiToken"` HasWarpSecret bool `json:"hasWarpSecret"` HasNordSecret bool `json:"hasNordSecret"` + HasSmtpPassword bool `json:"hasSmtpPassword"` } // CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values. diff --git a/internal/web/job/check_cpu_usage.go b/internal/web/job/check_cpu_usage.go index adb846e4b..b8ad68f2b 100644 --- a/internal/web/job/check_cpu_usage.go +++ b/internal/web/job/check_cpu_usage.go @@ -1,18 +1,16 @@ package job import ( - "strconv" "time" + "github.com/mhsanaei/3x-ui/v3/internal/eventbus" "github.com/mhsanaei/3x-ui/v3/internal/web/service" - "github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot" "github.com/shirou/gopsutil/v4/cpu" ) -// CheckCpuJob monitors CPU usage and sends Telegram notifications when usage exceeds the configured threshold. +// CheckCpuJob monitors CPU usage and publishes events when threshold is exceeded. type CheckCpuJob struct { - tgbotService tgbot.Tgbot settingService service.SettingService } @@ -21,21 +19,19 @@ func NewCheckCpuJob() *CheckCpuJob { return new(CheckCpuJob) } -// Run checks CPU usage over the last minute and sends a Telegram alert if it exceeds the threshold. +// Run checks CPU usage and publishes a cpu.high event with raw metric data. func (j *CheckCpuJob) Run() { - threshold, err := j.settingService.GetTgCpu() - if err != nil || threshold <= 0 { - // If threshold cannot be retrieved or is not set, skip sending notifications + percent, err := cpu.Percent(1*time.Minute, false) + if err != nil || len(percent) == 0 { return } - // get latest status of server - percent, err := cpu.Percent(1*time.Minute, false) - if err == nil && percent[0] > float64(threshold) { - msg := j.tgbotService.I18nBot("tgbot.messages.cpuThreshold", - "Percent=="+strconv.FormatFloat(percent[0], 'f', 2, 64), - "Threshold=="+strconv.Itoa(threshold)) - - j.tgbotService.SendMsgToTgbotAdmins(msg) + if EventBus != nil { + EventBus.Publish(eventbus.Event{ + Type: eventbus.EventCPUHigh, + Data: &eventbus.SystemMetricData{ + Percent: percent[0], + }, + }) } } diff --git a/internal/web/job/check_xray_running_job.go b/internal/web/job/check_xray_running_job.go index 3e3e394b9..51e4488d7 100644 --- a/internal/web/job/check_xray_running_job.go +++ b/internal/web/job/check_xray_running_job.go @@ -3,10 +3,14 @@ package job import ( + "github.com/mhsanaei/3x-ui/v3/internal/eventbus" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/web/service" ) +// EventBus is set from web layer to publish events. +var EventBus *eventbus.Bus + // CheckXrayRunningJob monitors Xray process health and restarts it if it crashes. type CheckXrayRunningJob struct { xrayService service.XrayService diff --git a/internal/web/job/node_heartbeat_job.go b/internal/web/job/node_heartbeat_job.go index d849b4e12..16111bf1e 100644 --- a/internal/web/job/node_heartbeat_job.go +++ b/internal/web/job/node_heartbeat_job.go @@ -2,10 +2,12 @@ package job import ( "context" + "strconv" "sync" "time" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/eventbus" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/web/websocket" @@ -70,6 +72,7 @@ func (j *NodeHeartbeatJob) Run() { func (j *NodeHeartbeatJob) probeOne(n *model.Node) { ctx, cancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout) defer cancel() + prevStatus := n.Status patch, err := j.nodeService.Probe(ctx, n) if err != nil { patch.Status = "offline" @@ -79,6 +82,7 @@ func (j *NodeHeartbeatJob) probeOne(n *model.Node) { if updErr := j.nodeService.UpdateHeartbeat(n.Id, patch); updErr != nil { logger.Warning("node heartbeat: update node", n.Id, "failed:", updErr) } + publishNodeTransition(n, prevStatus, patch) // Learn the nodes this node manages so the panel can surface them as // transitive sub-nodes (#4983). Fresh context — the probe budget above may // be spent. Drop them when the node is unreachable. @@ -90,3 +94,37 @@ func (j *NodeHeartbeatJob) probeOne(n *model.Node) { j.nodeService.ClearDescendants(n.Id) } } + +// publishNodeTransition emits node.down / node.up only on a genuine state change. +// An "unknown"/empty previous status (fresh start) is treated as not-online, so a +// node coming up for the first time fires node.up but never a spurious node.down. +func publishNodeTransition(n *model.Node, prevStatus string, patch service.HeartbeatPatch) { + if EventBus == nil { + return + } + var eventType eventbus.EventType + switch { + case prevStatus == "online" && patch.Status == "offline": + eventType = eventbus.EventNodeDown + case prevStatus != "online" && patch.Status == "online": + eventType = eventbus.EventNodeUp + default: + return + } + source := n.Name + if source == "" { + source = "node-" + strconv.Itoa(n.Id) + } + EventBus.Publish(eventbus.Event{ + Type: eventType, + Source: source, + Data: &eventbus.NodeHealthData{ + NodeId: n.Id, + LatencyMs: patch.LatencyMs, + CpuPct: patch.CpuPct, + MemPct: patch.MemPct, + XrayState: patch.XrayState, + XrayError: patch.XrayError, + }, + }) +} diff --git a/internal/web/service/email/email.go b/internal/web/service/email/email.go new file mode 100644 index 000000000..835d8730e --- /dev/null +++ b/internal/web/service/email/email.go @@ -0,0 +1,297 @@ +package email + +import ( + "crypto/tls" + "fmt" + "net" + "net/smtp" + "strings" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/web/service" +) + +// EmailService sends email notifications via SMTP. +type EmailService struct { + settingService service.SettingService +} + +// SMTPTestResult holds the result of an SMTP connection test. +type SMTPTestResult struct { + Success bool `json:"success"` + Stage string `json:"stage"` // "connect" | "auth" | "send" + Message string `json:"message"` // classified error message +} + +// NewEmailService creates a new EmailService. +func NewEmailService(settingService service.SettingService) *EmailService { + return &EmailService{settingService: settingService} +} + +// Send sends an HTML email to all configured recipients. +func (s *EmailService) Send(subject, body string) error { + host, err := s.settingService.GetSmtpHost() + if err != nil || host == "" { + return fmt.Errorf("smtp host not configured") + } + port, err := s.settingService.GetSmtpPort() + if err != nil || port <= 0 { + port = 587 + } + username, _ := s.settingService.GetSmtpUsername() + password, _ := s.settingService.GetSmtpPassword() + toStr, _ := s.settingService.GetSmtpTo() + encryptionType, _ := s.settingService.GetSmtpEncryptionType() + + from := username + if from == "" { + return fmt.Errorf("smtp from not configured") + } + + recipients := parseRecipients(toStr) + if len(recipients) == 0 { + return fmt.Errorf("no recipients configured") + } + + addr := net.JoinHostPort(host, fmt.Sprintf("%d", port)) + msg := buildMessage(from, recipients, subject, body) + + // Authenticate only when credentials are set. Go's PlainAuth refuses to run + // over the unencrypted "none" transport, so an open relay must use nil auth. + var auth smtp.Auth + if username != "" && password != "" { + auth = smtp.PlainAuth("", username, password, host) + } + + // Wrap in a channel with timeout to prevent indefinite blocking + type result struct{ err error } + ch := make(chan result, 1) + go func() { + switch encryptionType { + case "tls": + ch <- result{s.sendWithTLS(addr, auth, from, recipients, msg, host)} + case "starttls", "none": + ch <- result{smtp.SendMail(addr, auth, from, recipients, msg)} + default: + ch <- result{fmt.Errorf("unknown SMTP encryption type: %s", encryptionType)} + } + }() + + select { + case r := <-ch: + return r.err + case <-time.After(30 * time.Second): + return fmt.Errorf("smtp connection timed out after 30s") + } +} + +// TestConnection tests SMTP connection stage by stage and sends a test email. +func (s *EmailService) TestConnection() SMTPTestResult { + host, err := s.settingService.GetSmtpHost() + if err != nil || host == "" { + return SMTPTestResult{false, "connect", "smtpHostNotConfigured"} + } + port, err := s.settingService.GetSmtpPort() + if err != nil || port <= 0 { + port = 587 + } + username, _ := s.settingService.GetSmtpUsername() + password, _ := s.settingService.GetSmtpPassword() + toStr, _ := s.settingService.GetSmtpTo() + encryptionType, _ := s.settingService.GetSmtpEncryptionType() + + from := username + + recipients := parseRecipients(toStr) + if len(recipients) == 0 { + return SMTPTestResult{false, "send", "smtpNoRecipients"} + } + + addr := net.JoinHostPort(host, fmt.Sprintf("%d", port)) + + // Stage 1: Connect + var conn net.Conn + dialer := &net.Dialer{Timeout: 5 * time.Second} + + switch encryptionType { + case "tls": + conn, err = tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{ + ServerName: host, + InsecureSkipVerify: false, + }) + default: + conn, err = dialer.Dial("tcp", addr) + } + + if err != nil { + return SMTPTestResult{false, "connect", classifySMTPError(err)} + } + defer conn.Close() + + // Stage 2: Handshake + Auth + client, err := smtp.NewClient(conn, host) + if err != nil { + return SMTPTestResult{false, "auth", classifySMTPError(err)} + } + defer client.Close() + + if err = client.Hello("localhost"); err != nil { + return SMTPTestResult{false, "auth", classifySMTPError(err)} + } + + // STARTTLS upgrade for non-TLS connections + if encryptionType == "starttls" { + if ok, _ := client.Extension("STARTTLS"); ok { + if err = client.StartTLS(&tls.Config{ServerName: host}); err != nil { + return SMTPTestResult{false, "auth", classifySMTPError(err)} + } + } + } + + if username != "" && password != "" { + auth := smtp.PlainAuth("", username, password, host) + if err = client.Auth(auth); err != nil { + return SMTPTestResult{false, "auth", classifySMTPError(err)} + } + } + + // Stage 3: Send test email + if err = client.Mail(from); err != nil { + return SMTPTestResult{false, "send", classifySMTPError(err)} + } + for _, r := range recipients { + if err = client.Rcpt(r); err != nil { + return SMTPTestResult{false, "send", classifySMTPError(err)} + } + } + + msg := buildMessage(from, recipients, "[3x-ui] Test email", + ` +

Test email from 3x-ui

+

If you received this, SMTP is configured correctly.

+`) + + w, err := client.Data() + if err != nil { + return SMTPTestResult{false, "send", classifySMTPError(err)} + } + if _, err = w.Write(msg); err != nil { + return SMTPTestResult{false, "send", classifySMTPError(err)} + } + if err = w.Close(); err != nil { + return SMTPTestResult{false, "send", classifySMTPError(err)} + } + + return SMTPTestResult{true, "send", "smtpTestSuccess"} +} + +func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte, host string) error { + // Dial with explicit timeout + dialer := &net.Dialer{Timeout: 10 * time.Second} + conn, err := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{ + ServerName: host, + InsecureSkipVerify: false, + }) + if err != nil { + return err + } + defer conn.Close() + + client, err := smtp.NewClient(conn, host) + if err != nil { + return err + } + defer client.Close() + + if err = client.Hello("localhost"); err != nil { + return err + } + if auth != nil { + if err = client.Auth(auth); err != nil { + return err + } + } + if err = client.Mail(from); err != nil { + return err + } + for _, r := range to { + if err = client.Rcpt(r); err != nil { + return err + } + } + w, err := client.Data() + if err != nil { + return err + } + if _, err = w.Write(msg); err != nil { + return err + } + return w.Close() +} + +// SendTest sends a test email and returns any error with detail. +func (s *EmailService) SendTest() error { + return s.Send( + "[3x-ui] Test email", + ` +

Test email from 3x-ui

+

If you received this, SMTP is configured correctly.

+`, + ) +} + +// classifySMTPError maps raw SMTP errors to human-readable messages. +func classifySMTPError(err error) string { + msg := err.Error() + msgLower := strings.ToLower(msg) + + switch { + case strings.Contains(msg, "535") || strings.Contains(msgLower, "authentication"): + return "pages.settings.smtpErrorAuth" + case strings.Contains(msg, "534") || strings.Contains(msgLower, "starttls"): + return "pages.settings.smtpErrorStarttls" + case strings.Contains(msg, "465") || strings.Contains(msgLower, "tls"): + return "pages.settings.smtpErrorTls" + case strings.Contains(msgLower, "connection refused") || strings.Contains(msgLower, "dial"): + return "pages.settings.smtpErrorRefused" + case strings.Contains(msgLower, "timeout"): + return "pages.settings.smtpErrorTimeout" + case strings.Contains(msg, "550") || strings.Contains(msgLower, "relay"): + return "pages.settings.smtpErrorRelay" + case strings.Contains(msgLower, "eof"): + return "pages.settings.smtpErrorEof" + default: + return fmt.Sprintf("pages.settings.smtpErrorUnknown: %s", msg) + } +} + +func parseRecipients(toStr string) []string { + if toStr == "" { + return nil + } + var out []string + for _, s := range strings.Split(toStr, ",") { + s = strings.TrimSpace(s) + if s != "" { + out = append(out, s) + } + } + return out +} + +func buildMessage(from string, to []string, subject, body string) []byte { + headers := map[string]string{ + "From": from, + "To": strings.Join(to, ","), + "Subject": subject, + "MIME-Version": "1.0", + "Content-Type": "text/html; charset=utf-8", + } + var msg strings.Builder + for k, v := range headers { + msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v)) + } + msg.WriteString("\r\n") + msg.WriteString(body) + return []byte(msg.String()) +} diff --git a/internal/web/service/email/ratelimiter_test.go b/internal/web/service/email/ratelimiter_test.go new file mode 100644 index 000000000..4d990643e --- /dev/null +++ b/internal/web/service/email/ratelimiter_test.go @@ -0,0 +1,52 @@ +package email + +import ( + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/eventbus" +) + +func TestRateLimiterAllow(t *testing.T) { + rl := eventbus.NewRateLimiter(time.Minute) + + if !rl.Allow(eventbus.EventOutboundDown, "proxy-1") { + t.Error("first call should be allowed") + } +} + +func TestRateLimiterCooldown(t *testing.T) { + rl := eventbus.NewRateLimiter(100 * time.Millisecond) + + rl.Allow(eventbus.EventOutboundDown, "proxy-1") + + if rl.Allow(eventbus.EventOutboundDown, "proxy-1") { + t.Error("should be blocked during cooldown") + } + + time.Sleep(110 * time.Millisecond) + + if !rl.Allow(eventbus.EventOutboundDown, "proxy-1") { + t.Error("should be allowed after cooldown") + } +} + +func TestRateLimiterPerType(t *testing.T) { + rl := eventbus.NewRateLimiter(time.Minute) + + rl.Allow(eventbus.EventOutboundDown, "proxy-1") + + if !rl.Allow(eventbus.EventOutboundUp, "proxy-1") { + t.Error("different event types should be independent") + } +} + +func TestRateLimiterPerSource(t *testing.T) { + rl := eventbus.NewRateLimiter(time.Minute) + + rl.Allow(eventbus.EventOutboundDown, "proxy-1") + + if !rl.Allow(eventbus.EventOutboundDown, "proxy-2") { + t.Error("different sources should be independent") + } +} diff --git a/internal/web/service/email/subscriber.go b/internal/web/service/email/subscriber.go new file mode 100644 index 000000000..463a1e537 --- /dev/null +++ b/internal/web/service/email/subscriber.go @@ -0,0 +1,182 @@ +package email + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/eventbus" + "github.com/mhsanaei/3x-ui/v3/internal/logger" + "github.com/mhsanaei/3x-ui/v3/internal/web/locale" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" +) + +// Subscriber handles event bus messages and sends email notifications. +type Subscriber struct { + settingService service.SettingService + emailService *EmailService + limiter *eventbus.RateLimiter +} + +// NewSubscriber creates a new email event subscriber. +func NewSubscriber(settingService service.SettingService, emailService *EmailService) *Subscriber { + return &Subscriber{ + settingService: settingService, + emailService: emailService, + limiter: eventbus.NewRateLimiter(1 * time.Minute), + } +} + +// HandleEvent is the eventbus subscriber callback. +func (s *Subscriber) HandleEvent(e eventbus.Event) { + if !s.isEventEnabled(e.Type) { + return + } + if e.Type != eventbus.EventLoginAttempt { + if !s.limiter.Allow(e.Type, e.Source) { + return + } + } + subject, body := s.formatMessage(e) + if subject == "" { + return + } + if err := s.emailService.Send(subject, body); err != nil { + logger.Warning("email subscriber: send failed:", err) + } +} + +func (s *Subscriber) isEventEnabled(t eventbus.EventType) bool { + events, err := s.settingService.GetSmtpEnabledEvents() + if err != nil || events == "" { + return false + } + for _, e := range strings.Split(events, ",") { + if strings.TrimSpace(e) == string(t) { + return true + } + } + return false +} + +func i18n(key string, params ...string) string { + return locale.I18n(locale.Bot, key, params...) +} + +func (s *Subscriber) formatMessage(e eventbus.Event) (subject, body string) { + h, _ := hostname() + host := h + ts := e.Timestamp.Format("2006-01-02 15:04:05") + + wrap := func(title, content string) string { + // Strip newlines from title to prevent broken HTML + title = strings.ReplaceAll(title, "\r\n", "") + title = strings.ReplaceAll(title, "\n", "") + return fmt.Sprintf(` +

📡 %s %s

+%s +

%s

+`, host, title, content, i18n("tgbot.messages.time", "Time=="+ts)) + } + + kv := func(key, val string) string { + return fmt.Sprintf("

%s: %s

", key, val) + } + + switch e.Type { + case eventbus.EventOutboundDown: + subject = host + " " + i18n("tgbot.messages.eventOutboundDown", "Tag=="+e.Source) + content := kv(i18n("email.labelStatus"), ``+i18n("email.statusDown")+``) + content += kv(i18n("email.labelOutbound"), e.Source) + if data, ok := e.Data.(*eventbus.OutboundHealthData); ok { + if data.Error != "" { + content += kv(i18n("email.labelError"), data.Error) + } + if data.Delay > 0 { + content += kv(i18n("email.labelDelay"), fmt.Sprintf("%dms", data.Delay)) + } + } + body = wrap(i18n("tgbot.messages.eventOutboundDown", "Tag=="+e.Source), content) + + case eventbus.EventOutboundUp: + subject = host + " " + i18n("tgbot.messages.eventOutboundUp", "Tag=="+e.Source) + content := kv(i18n("email.labelStatus"), ``+i18n("email.statusUp")+``) + content += kv(i18n("email.labelOutbound"), e.Source) + if data, ok := e.Data.(*eventbus.OutboundHealthData); ok && data.Delay > 0 { + content += kv(i18n("email.labelDelay"), fmt.Sprintf("%dms", data.Delay)) + } + body = wrap(i18n("tgbot.messages.eventOutboundUp", "Tag=="+e.Source), content) + + case eventbus.EventXrayCrash: + subject = host + " " + i18n("tgbot.messages.eventXrayCrash") + content := kv(i18n("email.labelStatus"), ``+i18n("email.statusCrashed")+``) + if e.Data != nil { + content += kv(i18n("email.labelError"), fmt.Sprint(e.Data)) + } + body = wrap(i18n("tgbot.messages.eventXrayCrash"), content) + + case eventbus.EventNodeDown: + subject = host + " " + i18n("tgbot.messages.eventNodeDown", "Name=="+e.Source) + content := kv(i18n("email.labelStatus"), ``+i18n("email.statusDown")+``) + content += kv(i18n("email.labelNode"), e.Source) + if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.XrayError != "" { + content += kv(i18n("email.labelError"), data.XrayError) + } + body = wrap(i18n("tgbot.messages.eventNodeDown", "Name=="+e.Source), content) + + case eventbus.EventNodeUp: + subject = host + " " + i18n("tgbot.messages.eventNodeUp", "Name=="+e.Source) + content := kv(i18n("email.labelStatus"), ``+i18n("email.statusUp")+``) + content += kv(i18n("email.labelNode"), e.Source) + if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.LatencyMs > 0 { + content += kv(i18n("email.labelDelay"), fmt.Sprintf("%dms", data.LatencyMs)) + } + body = wrap(i18n("tgbot.messages.eventNodeUp", "Name=="+e.Source), content) + + case eventbus.EventCPUHigh: + if data, ok := e.Data.(*eventbus.SystemMetricData); ok { + smtpCpu, err := s.settingService.GetSmtpCpu() + if err != nil || smtpCpu <= 0 || data.Percent <= float64(smtpCpu) { + return + } + subject = host + " " + i18n("tgbot.messages.cpuThreshold", + "Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64), + "Threshold=="+fmt.Sprintf("%d", smtpCpu)) + content := kv(i18n("email.labelStatus"), ``+i18n("email.statusHigh")+``) + body = wrap(subject, content) + } + + case eventbus.EventLoginAttempt: + if data, ok := e.Data.(*eventbus.LoginEventData); ok { + if data.Status == "success" { + subject = host + " " + i18n("tgbot.messages.loginSuccess") + content := kv(i18n("email.labelStatus"), ``+i18n("email.statusSuccess")+``) + content += kv(i18n("email.labelUsername"), data.Username) + content += kv(i18n("email.labelIP"), data.IP) + body = wrap(i18n("tgbot.messages.loginSuccess"), content) + } else { + subject = host + " " + i18n("tgbot.messages.loginFailed") + content := kv(i18n("email.labelStatus"), ``+i18n("email.statusFailed")+``) + if data.Reason != "" { + content += kv(i18n("email.labelReason"), data.Reason) + } + content += kv(i18n("email.labelUsername"), data.Username) + content += kv(i18n("email.labelIP"), data.IP) + body = wrap(i18n("tgbot.messages.loginFailed"), content) + } + } else { + subject = host + " " + i18n("tgbot.messages.loginFailed") + content := kv(i18n("email.labelStatus"), ``+i18n("email.statusFailed")+``) + content += kv(i18n("email.labelSource"), e.Source) + body = wrap(i18n("tgbot.messages.loginFailed"), content) + } + } + + return +} + +func hostname() (string, error) { + return os.Hostname() +} diff --git a/internal/web/service/setting.go b/internal/web/service/setting.go index d1f65ef43..6c107a1f0 100644 --- a/internal/web/service/setting.go +++ b/internal/web/service/setting.go @@ -53,7 +53,6 @@ var defaultValueMap = map[string]string{ "tgBotChatId": "", "tgRunTime": "@daily", "tgBotBackup": "false", - "tgBotLoginNotify": "true", "tgCpu": "80", "tgLang": "en-US", "twoFactorEnable": "false", @@ -119,6 +118,20 @@ var defaultValueMap = map[string]string{ "ldapDefaultTotalGB": "0", "ldapDefaultExpiryDays": "0", "ldapDefaultLimitIP": "0", + + // Event bus — per-subscriber event filtering (empty = all disabled) + "tgEnabledEvents": "login.attempt,cpu.high", + "smtpEnabledEvents": "login.attempt,cpu.high", + "smtpCpu": "80", + + // Email (SMTP) notifications + "smtpEnable": "false", + "smtpHost": "", + "smtpPort": "587", + "smtpUsername": "", + "smtpPassword": "", + "smtpTo": "", + "smtpEncryptionType": "starttls", // no, starttls, tls } // SettingService provides business logic for application settings management. @@ -220,6 +233,7 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) { view.HasLdapPassword = secretConfigured(allSetting.LdapPassword) view.HasWarpSecret = secretConfigured(mustString(s.GetWarp())) view.HasNordSecret = secretConfigured(mustString(s.GetNord())) + view.HasSmtpPassword = secretConfigured(allSetting.SmtpPassword) var apiTokenCount int64 if err := database.GetDB().Model(model.ApiToken{}).Where("enabled = ?", true).Count(&apiTokenCount).Error; err == nil { view.HasApiToken = apiTokenCount > 0 @@ -227,6 +241,7 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) { view.TgBotToken = "" view.TwoFactorToken = "" view.LdapPassword = "" + view.SmtpPassword = "" return view, nil } @@ -504,10 +519,6 @@ func (s *SettingService) GetTgBotBackup() (bool, error) { return s.getBool("tgBotBackup") } -func (s *SettingService) GetTgBotLoginNotify() (bool, error) { - return s.getBool("tgBotLoginNotify") -} - func (s *SettingService) GetTgCpu() (int, error) { return s.getInt("tgCpu") } @@ -918,6 +929,90 @@ func (s *SettingService) GetLdapDefaultLimitIP() (int, error) { return s.getInt("ldapDefaultLimitIP") } +// Event bus — per-subscriber event filtering + +func (s *SettingService) GetTgEnabledEvents() (string, error) { + return s.getString("tgEnabledEvents") +} + +func (s *SettingService) SetTgEnabledEvents(events string) error { + return s.setString("tgEnabledEvents", events) +} + +func (s *SettingService) GetSmtpEnabledEvents() (string, error) { + return s.getString("smtpEnabledEvents") +} + +func (s *SettingService) SetSmtpEnabledEvents(events string) error { + return s.setString("smtpEnabledEvents", events) +} + +// Email (SMTP) settings + +func (s *SettingService) GetSmtpEnable() (bool, error) { + return s.getBool("smtpEnable") +} + +func (s *SettingService) SetSmtpEnable(value bool) error { + return s.setBool("smtpEnable", value) +} + +func (s *SettingService) GetSmtpHost() (string, error) { + return s.getString("smtpHost") +} + +func (s *SettingService) SetSmtpHost(value string) error { + return s.setString("smtpHost", value) +} + +func (s *SettingService) GetSmtpPort() (int, error) { + return s.getInt("smtpPort") +} + +func (s *SettingService) SetSmtpPort(value int) error { + return s.setInt("smtpPort", value) +} + +func (s *SettingService) GetSmtpUsername() (string, error) { + return s.getString("smtpUsername") +} + +func (s *SettingService) SetSmtpUsername(value string) error { + return s.setString("smtpUsername", value) +} + +func (s *SettingService) GetSmtpPassword() (string, error) { + return s.getString("smtpPassword") +} + +func (s *SettingService) SetSmtpPassword(value string) error { + return s.setString("smtpPassword", value) +} + +func (s *SettingService) GetSmtpTo() (string, error) { + return s.getString("smtpTo") +} + +func (s *SettingService) SetSmtpTo(value string) error { + return s.setString("smtpTo", value) +} + +func (s *SettingService) GetSmtpEncryptionType() (string, error) { + return s.getString("smtpEncryptionType") +} + +func (s *SettingService) SetSmtpEncryptionType(value string) error { + return s.setString("smtpEncryptionType", value) +} + +func (s *SettingService) GetSmtpCpu() (int, error) { + return s.getInt("smtpCpu") +} + +func (s *SettingService) SetSmtpCpu(value int) error { + return s.setInt("smtpCpu", value) +} + func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error { if err := s.preserveRedactedSecrets(allSetting); err != nil { return err @@ -967,6 +1062,13 @@ func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting) } allSetting.TwoFactorToken = value } + if strings.TrimSpace(allSetting.SmtpPassword) == "" { + value, err := s.GetSmtpPassword() + if err != nil { + return err + } + allSetting.SmtpPassword = value + } return nil } diff --git a/internal/web/service/setting_security_test.go b/internal/web/service/setting_security_test.go index 3545be3c5..1939b0e13 100644 --- a/internal/web/service/setting_security_test.go +++ b/internal/web/service/setting_security_test.go @@ -32,6 +32,9 @@ func TestGetAllSettingViewRedactsSecrets(t *testing.T) { if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil { t.Fatal(err) } + if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil { + t.Fatal(err) + } if err := database.GetDB().Create(&model.ApiToken{Name: "test", Token: "api-secret", Enabled: true}).Error; err != nil { t.Fatal(err) } @@ -40,10 +43,10 @@ func TestGetAllSettingViewRedactsSecrets(t *testing.T) { if err != nil { t.Fatal(err) } - if view.TgBotToken != "" || view.TwoFactorToken != "" || view.LdapPassword != "" { + if view.TgBotToken != "" || view.TwoFactorToken != "" || view.LdapPassword != "" || view.SmtpPassword != "" { t.Fatalf("settings view leaked secrets: %#v", view) } - if !view.HasTgBotToken || !view.HasTwoFactorToken || !view.HasLdapPassword || !view.HasApiToken { + if !view.HasTgBotToken || !view.HasTwoFactorToken || !view.HasLdapPassword || !view.HasApiToken || !view.HasSmtpPassword { t.Fatalf("settings view did not report configured secret flags: %#v", view) } } @@ -63,6 +66,9 @@ func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) { if err := s.saveSetting("twoFactorToken", "totp-secret"); err != nil { t.Fatal(err) } + if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil { + t.Fatal(err) + } view, err := s.GetAllSettingView() if err != nil { @@ -81,6 +87,9 @@ func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) { if got, _ := s.GetTwoFactorToken(); got != "totp-secret" { t.Fatalf("2fa token = %q, want preserved secret", got) } + if got, _ := s.GetSmtpPassword(); got != "smtp-secret" { + t.Fatalf("smtp password = %q, want preserved secret", got) + } } func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) { diff --git a/internal/web/service/tgbot/tgbot.go b/internal/web/service/tgbot/tgbot.go index effa871a8..c6b45c17d 100644 --- a/internal/web/service/tgbot/tgbot.go +++ b/internal/web/service/tgbot/tgbot.go @@ -15,6 +15,7 @@ import ( "sync" "time" + "github.com/mhsanaei/3x-ui/v3/internal/eventbus" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/util/common" "github.com/mhsanaei/3x-ui/v3/internal/web/global" @@ -43,6 +44,9 @@ var ( hostname string hashStorage *global.HashStorage + // EventBus is set from web layer to publish login/security events. + EventBus *eventbus.Bus + // Performance improvements messageWorkerPool chan struct{} // Semaphore for limiting concurrent message processing optimizedHTTPClient *http.Client // HTTP client with connection pooling and timeouts diff --git a/internal/web/service/tgbot/tgbot_event.go b/internal/web/service/tgbot/tgbot_event.go new file mode 100644 index 000000000..f4849756d --- /dev/null +++ b/internal/web/service/tgbot/tgbot_event.go @@ -0,0 +1,150 @@ +package tgbot + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/eventbus" +) + +var cachedHostname string + +func getHostname() string { + if cachedHostname != "" { + return cachedHostname + } + h, err := os.Hostname() + if err != nil { + cachedHostname = "unknown" + } else { + cachedHostname = h + } + return cachedHostname +} + +var tgEventLimiter = eventbus.NewRateLimiter(1 * time.Minute) + +// HandleEvent is the eventbus subscriber callback. It formats incoming events +// as Telegram messages and sends them to all admin chats. +func (t *Tgbot) HandleEvent(e eventbus.Event) { + if !t.isEventEnabled(e.Type) { + return + } + if e.Type != eventbus.EventLoginAttempt { + if !tgEventLimiter.Allow(e.Type, e.Source) { + return + } + } + msg := t.formatEventMessage(e) + if msg != "" { + t.SendMsgToTgbotAdmins(msg) + } +} + +func (t *Tgbot) isEventEnabled(eventType eventbus.EventType) bool { + events, err := t.settingService.GetTgEnabledEvents() + if err != nil || events == "" { + return false + } + for _, e := range strings.Split(events, ",") { + if strings.TrimSpace(e) == string(eventType) { + return true + } + } + return false +} + +func (t *Tgbot) formatEventMessage(e eventbus.Event) string { + host := getHostname() + header := fmt.Sprintf("📡 %s\n", host) + + switch e.Type { + case eventbus.EventOutboundDown: + msg := header + t.I18nBot("tgbot.messages.eventOutboundDown", + "Tag=="+e.Source) + if data, ok := e.Data.(*eventbus.OutboundHealthData); ok { + if data.Error != "" { + msg += "\n" + t.I18nBot("tgbot.messages.eventErrorDetail", + "Error=="+data.Error) + } + if data.Delay > 0 { + msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail", + "Delay=="+fmt.Sprintf("%d", data.Delay)) + } + } + return msg + + case eventbus.EventOutboundUp: + msg := header + t.I18nBot("tgbot.messages.eventOutboundUp", + "Tag=="+e.Source) + if data, ok := e.Data.(*eventbus.OutboundHealthData); ok && data.Delay > 0 { + msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail", + "Delay=="+fmt.Sprintf("%d", data.Delay)) + } + return msg + + case eventbus.EventXrayCrash: + errStr := "" + if e.Data != nil { + errStr = fmt.Sprint(e.Data) + } + msg := header + "🔥 " + t.I18nBot("tgbot.messages.eventXrayCrash") + if errStr != "" { + msg += "\n" + t.I18nBot("tgbot.messages.eventXrayCrashError", "Error=="+errStr) + } + return msg + + case eventbus.EventNodeDown: + msg := header + "🔴 " + t.I18nBot("tgbot.messages.eventNodeDown", "Name=="+e.Source) + if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.XrayError != "" { + msg += "\n" + t.I18nBot("tgbot.messages.eventErrorDetail", "Error=="+data.XrayError) + } + return msg + + case eventbus.EventNodeUp: + msg := header + "🟢 " + t.I18nBot("tgbot.messages.eventNodeUp", "Name=="+e.Source) + if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.LatencyMs > 0 { + msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail", "Delay=="+fmt.Sprintf("%d", data.LatencyMs)) + } + return msg + + case eventbus.EventCPUHigh: + if data, ok := e.Data.(*eventbus.SystemMetricData); ok { + tgCpu, err := t.settingService.GetTgCpu() + if err != nil || tgCpu <= 0 || data.Percent <= float64(tgCpu) { + return "" + } + return header + "🔴 " + t.I18nBot("tgbot.messages.cpuThreshold", + "Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64), + "Threshold=="+strconv.Itoa(tgCpu)) + } + return "" + + case eventbus.EventLoginAttempt: + if data, ok := e.Data.(*eventbus.LoginEventData); ok { + if data.Status == "success" { + msg := t.I18nBot("tgbot.messages.loginSuccess") + msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+host) + msg += t.I18nBot("tgbot.messages.username", "Username=="+data.Username) + msg += t.I18nBot("tgbot.messages.ip", "IP=="+data.IP) + msg += t.I18nBot("tgbot.messages.time", "Time=="+data.Time) + return msg + } + msg := t.I18nBot("tgbot.messages.loginFailed") + msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+host) + if data.Reason != "" { + msg += t.I18nBot("tgbot.messages.reason", "Reason=="+data.Reason) + } + msg += t.I18nBot("tgbot.messages.username", "Username=="+data.Username) + msg += t.I18nBot("tgbot.messages.ip", "IP=="+data.IP) + msg += t.I18nBot("tgbot.messages.time", "Time=="+data.Time) + return msg + } + return header + t.I18nBot("tgbot.messages.eventLoginFallback", "Source=="+e.Source) + } + + return "" +} diff --git a/internal/web/service/tgbot/tgbot_report.go b/internal/web/service/tgbot/tgbot_report.go index f23ff3f16..d0fecd622 100644 --- a/internal/web/service/tgbot/tgbot_report.go +++ b/internal/web/service/tgbot/tgbot_report.go @@ -12,6 +12,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/config" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/eventbus" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/util/common" "github.com/mhsanaei/3x-ui/v3/internal/web/service" @@ -153,38 +154,33 @@ func (t *Tgbot) prepareServerUsageInfo() string { return info } -// UserLoginNotify sends a notification about user login attempts to admins. +// UserLoginNotify publishes a login event to the event bus. func (t *Tgbot) UserLoginNotify(attempt LoginAttempt) { - if !t.IsRunning() { - return - } - if attempt.Username == "" || attempt.IP == "" || attempt.Time == "" { logger.Warning("UserLoginNotify failed, invalid info!") return } - loginNotifyEnabled, err := t.settingService.GetTgBotLoginNotify() - if err != nil || !loginNotifyEnabled { + if EventBus == nil { return } - msg := "" - switch attempt.Status { - case LoginSuccess: - msg += t.I18nBot("tgbot.messages.loginSuccess") - msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname) - case LoginFail: - msg += t.I18nBot("tgbot.messages.loginFailed") - msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname) - if attempt.Reason != "" { - msg += t.I18nBot("tgbot.messages.reason", "Reason=="+attempt.Reason) - } + status := "fail" + if attempt.Status == LoginSuccess { + status = "success" } - msg += t.I18nBot("tgbot.messages.username", "Username=="+attempt.Username) - msg += t.I18nBot("tgbot.messages.ip", "IP=="+attempt.IP) - msg += t.I18nBot("tgbot.messages.time", "Time=="+attempt.Time) - go t.SendMsgToTgbotAdmins(msg) + + EventBus.Publish(eventbus.Event{ + Type: eventbus.EventLoginAttempt, + Source: attempt.IP, + Data: &eventbus.LoginEventData{ + Username: attempt.Username, + IP: attempt.IP, + Time: attempt.Time, + Status: status, + Reason: attempt.Reason, + }, + }) } // getExhausted retrieves and sends information about exhausted clients. diff --git a/internal/web/service/tgbot/tgbot_send.go b/internal/web/service/tgbot/tgbot_send.go index 2cd1cf5b0..c38a4a22c 100644 --- a/internal/web/service/tgbot/tgbot_send.go +++ b/internal/web/service/tgbot/tgbot_send.go @@ -2,6 +2,7 @@ package tgbot import ( "context" + "fmt" "strings" "time" @@ -247,3 +248,19 @@ func (t *Tgbot) deleteMessageTgBot(chatId int64, messageID int) { logger.Info("Message deleted successfully") } } + +// TestConnection verifies the bot token is valid and the API is reachable. +func (t *Tgbot) TestConnection() error { + tgBotMutex.Lock() + b := bot + tgBotMutex.Unlock() + if b == nil { + return fmt.Errorf("bot not initialized") + } + me, err := b.GetMe(context.Background()) + if err != nil { + return fmt.Errorf("API unreachable: %w", err) + } + _ = me + return nil +} diff --git a/internal/web/service/xray_metrics.go b/internal/web/service/xray_metrics.go index d1004a403..95583ef3e 100644 --- a/internal/web/service/xray_metrics.go +++ b/internal/web/service/xray_metrics.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "github.com/mhsanaei/3x-ui/v3/internal/eventbus" "github.com/mhsanaei/3x-ui/v3/internal/logger" ) @@ -29,6 +30,15 @@ type ObsTagSnapshot struct { UpdatedAt int64 `json:"updatedAt"` } +// eventBus is the shared bus for publishing observatory state-change events. +// Set once during startup via SetEventBus; nil when no bus is configured. +var eventBus *eventbus.Bus + +// SetEventBus assigns the global event bus used by applyObservatory to publish +// outbound health transitions. Must be called once during startup before any +// Sample tick runs. +func SetEventBus(b *eventbus.Bus) { eventBus = b } + type XrayMetricsService struct { settingService SettingService @@ -205,6 +215,35 @@ func (s *XrayMetricsService) applyObservatory(t time.Time, entries map[string]ra } s.mu.Lock() + // Detect transitions and publish events + if eventBus != nil { + // Check existing tags for state changes + for tag, old := range s.obsByTag { + cur, exists := next[tag] + if !exists { + // Tag disappeared from observatory — skip, not a real failure + continue + } + if old.Alive && !cur.Alive { + errMsg := "" + if cur.Delay < 0 { + errMsg = "probe failed" + } + eventBus.Publish(eventbus.Event{ + Type: eventbus.EventOutboundDown, + Source: tag, + Data: &eventbus.OutboundHealthData{Delay: cur.Delay, Error: errMsg}, + }) + } else if !old.Alive && cur.Alive { + eventBus.Publish(eventbus.Event{ + Type: eventbus.EventOutboundUp, + Source: tag, + Data: &eventbus.OutboundHealthData{Delay: cur.Delay}, + }) + } + } + } + for tag := range s.obsByTag { if _, kept := next[tag]; !kept { xrayMetrics.drop(obsHistoryKey(tag)) diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 62d342458..87906f7c0 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -1200,7 +1200,69 @@ "userPassMustBeNotEmpty": "اسم المستخدم والباسورد الجديدين فاضيين", "getOutboundTrafficError": "خطأ في الحصول على حركات المرور الصادرة", "resetOutboundTrafficError": "خطأ في إعادة تعيين حركات المرور الصادرة" - } + }, + "emailNotifications": "الإشعارات", + "emailSettings": "البريد الإلكتروني", + "eventCPUHigh": "ارتفاع استخدام المعالج (%)", + "eventGroupOutbound": "الصادر", + "eventGroupSecurity": "الأمان", + "eventGroupSystem": "النظام", + "eventGroupXray": "نواة Xray", + "eventLoginAttempt": "محاولة تسجيل دخول", + "eventOutboundDown": "غير متصل", + "eventOutboundUp": "متصل", + "eventXrayCrash": "تعطّل", + "requestFailed": "فشل الطلب", + "smtpEnable": "تفعيل إشعارات البريد الإلكتروني", + "smtpEnableDesc": "تفعيل إشعارات البريد الإلكتروني عبر SMTP", + "smtpEncryption": "التشفير", + "smtpEncryptionDesc": "طريقة تشفير اتصال SMTP", + "smtpEncryptionNone": "بدون (نص عادي)", + "smtpEncryptionStartTLS": "STARTTLS", + "smtpEncryptionTLS": "TLS (ضمني)", + "smtpEventBusNotify": "إشعارات الأحداث بالبريد الإلكتروني", + "smtpEventBusNotifyDesc": "اختر الأحداث التي تُطلق إشعارات البريد الإلكتروني", + "smtpHost": "خادم SMTP", + "smtpHostDesc": "اسم مضيف خادم SMTP (مثال: smtp.gmail.com)", + "smtpHostNotConfigured": "خادم SMTP غير مهيأ", + "smtpNoRecipients": "لا يوجد مستلمون مهيؤون", + "smtpNotInitialized": "لم تتم تهيئة SMTP", + "smtpPassword": "كلمة مرور SMTP", + "smtpPasswordDesc": "كلمة المرور للمصادقة على SMTP", + "smtpPort": "منفذ SMTP", + "smtpPortDesc": "منفذ خادم SMTP (الافتراضي: 587)", + "smtpSettings": "إعدادات SMTP", + "smtpStageAuth": "المصادقة", + "smtpStageConnect": "الاتصال", + "smtpStageSend": "الإرسال", + "smtpTestSuccess": "تم إرسال البريد التجريبي بنجاح", + "smtpTo": "المستلمون", + "smtpToDesc": "عناوين البريد الإلكتروني للمستلمين مفصولة بفواصل", + "smtpUsername": "اسم مستخدم SMTP", + "smtpUsernameDesc": "اسم المستخدم للمصادقة على SMTP", + "telegramTokenConfigured": "مهيأ؛ اتركه فارغاً للاحتفاظ بالتوكن الحالي.", + "telegramTokenPlaceholder": "مهيأ — أدخل توكن جديد لاستبداله", + "testSmtp": "إرسال بريد تجريبي", + "testTgBot": "إرسال رسالة تجريبية", + "tgBotNotEnabled": "بوت Telegram غير مفعّل", + "tgBotNotRunning": "بوت Telegram لا يعمل", + "tgEventBusNotify": "إشعارات الأحداث عبر Telegram", + "tgEventBusNotifyDesc": "اختر الأحداث التي تُطلق إشعارات Telegram", + "tgTestFailed": "فشل اختبار Telegram", + "tgTestSuccess": "تم إرسال رسالة تجريبية إلى Telegram", + "smtpErrorAuth": "فشلت المصادقة — تحقق من اسم المستخدم وكلمة المرور", + "smtpErrorStarttls": "الخادم يتطلب STARTTLS — غيّر نوع التشفير", + "smtpErrorTls": "الخادم يتطلب TLS — غيّر نوع التشفير", + "smtpErrorRefused": "تم رفض الاتصال — تحقق من الخادم والمنفذ", + "smtpErrorTimeout": "انتهت مهلة الاتصال — تعذّر الوصول إلى الخادم", + "smtpErrorRelay": "الخادم يرفض الإرسال من هذا العنوان", + "smtpErrorEof": "تم إغلاق الاتصال من قبل الخادم", + "smtpErrorUnknown": "خطأ SMTP: {{ .Error }}", + "eventGroupNode": "العقد", + "eventNodeDown": "غير متصلة", + "eventNodeUp": "متصلة", + "smtpPasswordConfigured": "مهيأة؛ اتركها فارغة للاحتفاظ بكلمة المرور الحالية.", + "smtpPasswordPlaceholder": "مهيأة — أدخل كلمة مرور جديدة لاستبدالها" }, "xray": { "title": "إعدادات Xray", @@ -1703,7 +1765,18 @@ "AreYouSure": "إنت متأكد؟ 🤔", "SuccessResetTraffic": "📧 البريد الإلكتروني: {{ .ClientEmail }}\n🏁 النتيجة: ✅ تم بنجاح", "FailedResetTraffic": "📧 البريد الإلكتروني: {{ .ClientEmail }}\n🏁 النتيجة: ❌ فشل \n\n🛠️ الخطأ: [ {{ .ErrorMessage }} ]", - "FinishProcess": "🔚 عملية إعادة ضبط الترافيك خلصت لكل العملاء." + "FinishProcess": "🔚 عملية إعادة ضبط الترافيك خلصت لكل العملاء.", + "eventCPUHigh": "ارتفاع استخدام المعالج", + "eventCPUHighDetail": "المعالج: {{ .Detail }}", + "eventDelayDetail": "التأخير: {{ .Delay }} مللي ثانية", + "eventErrorDetail": "الخطأ: {{ .Error }}", + "eventLoginFallback": "فشل تسجيل الدخول من {{ .Source }}", + "eventOutboundDown": "الصادر {{ .Tag }} غير متصل", + "eventOutboundUp": "الصادر {{ .Tag }} متصل", + "eventXrayCrash": "تعطّل Xray", + "eventXrayCrashError": "الخطأ: {{ .Error }}", + "eventNodeDown": "العقدة {{ .Name }} غير متصلة", + "eventNodeUp": "العقدة {{ .Name }} متصلة" }, "buttons": { "closeKeyboard": "❌ اقفل الكيبورد", @@ -1773,5 +1846,57 @@ "chooseClient": "اختار عميل للإدخال {{ .Inbound }}", "chooseInbound": "اختار الإدخال" } + }, + "email": { + "labelDelay": "التأخير", + "labelDetail": "التفاصيل", + "labelError": "الخطأ", + "labelIP": "IP", + "labelOutbound": "الصادر", + "labelReason": "السبب", + "labelSource": "المصدر", + "labelStatus": "الحالة", + "labelTime": "الوقت", + "labelUsername": "اسم المستخدم", + "statusBanned": "BANNED", + "statusCrashed": "متعطّل", + "statusDown": "غير متصل", + "statusFailed": "فشل", + "statusFull": "FULL", + "statusHigh": "مرتفع", + "statusOffline": "OFFLINE", + "statusOnline": "ONLINE", + "statusRunning": "يعمل", + "statusSuccess": "نجاح", + "statusUp": "متصل", + "statusXrayDown": "Xray DOWN", + "statusXrayUp": "Xray UP", + "subjectCPUHigh": "ارتفاع استخدام المعالج", + "subjectDiskFull": "Disk full", + "subjectIPBanned": "IP banned: {{ .IP }}", + "subjectLoginFailed": "فشل تسجيل الدخول", + "subjectLoginSuccess": "نجح تسجيل الدخول", + "subjectNodeOffline": "Node {{ .Node }} is OFFLINE", + "subjectNodeOnline": "Node {{ .Node }} is ONLINE", + "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN", + "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP", + "subjectOutboundDown": "الصادر {{ .Tag }} غير متصل", + "subjectOutboundUp": "الصادر {{ .Tag }} متصل", + "subjectXrayCrash": "تعطّل Xray", + "subjectXrayUp": "Xray is UP", + "titleCPUHigh": "ارتفاع استخدام المعالج", + "titleDiskFull": "Disk full", + "titleIPBanned": "IP banned", + "titleLoginFailed": "فشل تسجيل الدخول", + "titleLoginSuccess": "نجح تسجيل الدخول", + "titleNodeOffline": "Node OFFLINE", + "titleNodeOnline": "Node ONLINE", + "titleNodeXrayDown": "Node Xray DOWN", + "titleNodeXrayUp": "Node Xray UP", + "titleOutboundDown": "الصادر غير متصل", + "titleOutboundUp": "الصادر متصل", + "titleXrayCrash": "تعطّل Xray", + "titleXrayUp": "Xray UP", + "labelNode": "العقدة" } } \ No newline at end of file diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index cf3391f37..afc3c7f4d 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -1201,7 +1201,69 @@ "userPassMustBeNotEmpty": "The new username and password are empty", "getOutboundTrafficError": "Error getting traffic", "resetOutboundTrafficError": "Error resetting outbound traffic" - } + }, + "smtpSettings": "SMTP Settings", + "smtpEnable": "Enable Email Notifications", + "smtpEnableDesc": "Enable email notifications via SMTP", + "smtpHost": "SMTP Host", + "smtpHostDesc": "SMTP server hostname (e.g. smtp.gmail.com)", + "smtpPort": "SMTP Port", + "smtpPortDesc": "SMTP server port (default: 587)", + "smtpUsername": "SMTP Username", + "smtpUsernameDesc": "SMTP authentication username", + "smtpPassword": "SMTP Password", + "smtpPasswordDesc": "SMTP authentication password", + "smtpTo": "Recipients", + "smtpToDesc": "Comma-separated recipient email addresses", + "emailSettings": "Email", + "emailNotifications": "Notifications", + "smtpEventBusNotify": "Email Event Notifications", + "smtpEventBusNotifyDesc": "Select which events trigger email notifications", + "tgEventBusNotify": "Telegram Event Notifications", + "tgEventBusNotifyDesc": "Select which events trigger Telegram notifications", + "testSmtp": "Send Test Email", + "testTgBot": "Send Test Message", + "eventGroupOutbound": "Outbound", + "eventGroupXray": "Xray Core", + "eventGroupSystem": "System", + "eventGroupSecurity": "Security", + "eventGroupNode": "Nodes", + "eventOutboundDown": "Down", + "eventOutboundUp": "Up", + "eventXrayCrash": "Crash", + "eventNodeDown": "Down", + "eventNodeUp": "Up", + "eventCPUHigh": "CPU high (%)", + "requestFailed": "Request failed", + "smtpEncryption": "Encryption", + "smtpEncryptionDesc": "SMTP connection encryption method", + "smtpEncryptionNone": "None (plain text)", + "smtpEncryptionStartTLS": "STARTTLS", + "smtpEncryptionTLS": "TLS (implicit)", + "smtpStageConnect": "Connection", + "smtpStageAuth": "Authentication", + "smtpStageSend": "Send", + "smtpTestSuccess": "Test email sent successfully", + "smtpHostNotConfigured": "SMTP host not configured", + "smtpNoRecipients": "No recipients configured", + "eventLoginAttempt": "Login attempt", + "telegramTokenConfigured": "Configured; leave blank to keep current token.", + "telegramTokenPlaceholder": "Configured - enter a new token to replace", + "smtpPasswordConfigured": "Configured; leave blank to keep current password.", + "smtpPasswordPlaceholder": "Configured - enter a new password to replace", + "smtpNotInitialized": "SMTP not initialized", + "tgBotNotEnabled": "Telegram bot is not enabled", + "tgTestFailed": "Telegram test failed", + "tgTestSuccess": "Test message sent to Telegram", + "tgBotNotRunning": "Telegram bot not running", + "smtpErrorAuth": "Authentication failed — check username and password", + "smtpErrorStarttls": "Server requires STARTTLS — change encryption type", + "smtpErrorTls": "Server requires TLS — change encryption type", + "smtpErrorRefused": "Connection refused — check host and port", + "smtpErrorTimeout": "Connection timeout — host unreachable", + "smtpErrorRelay": "Server rejects sending from this address", + "smtpErrorEof": "Connection closed by server", + "smtpErrorUnknown": "SMTP error: {{ .Error }}" }, "xray": { "title": "Xray Configs", @@ -1704,7 +1766,18 @@ "AreYouSure": "Are you sure? 🤔", "SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Result: ✅ Success", "FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Result: ❌ Failed \n\n🛠️ Error: [ {{ .ErrorMessage }} ]", - "FinishProcess": "🔚 Traffic reset process finished for all clients." + "FinishProcess": "🔚 Traffic reset process finished for all clients.", + "eventOutboundDown": "Outbound {{ .Tag }} is DOWN", + "eventOutboundUp": "Outbound {{ .Tag }} is UP", + "eventErrorDetail": "Error: {{ .Error }}", + "eventDelayDetail": "Delay: {{ .Delay }}ms", + "eventXrayCrash": "Xray CRASHED", + "eventXrayCrashError": "Error: {{ .Error }}", + "eventNodeDown": "Node {{ .Name }} is DOWN", + "eventNodeUp": "Node {{ .Name }} is UP", + "eventCPUHigh": "CPU high", + "eventCPUHighDetail": "CPU: {{ .Detail }}", + "eventLoginFallback": "Login failed from {{ .Source }}" }, "buttons": { "closeKeyboard": "❌ Close Keyboard", @@ -1774,5 +1847,37 @@ "chooseClient": "Choose a Client for Inbound {{ .Inbound }}", "chooseInbound": "Choose an Inbound" } + }, + "email": { + "subjectOutboundDown": "Outbound {{ .Tag }} is DOWN", + "subjectOutboundUp": "Outbound {{ .Tag }} is UP", + "subjectXrayCrash": "Xray CRASHED", + "subjectCPUHigh": "CPU high", + "subjectLoginSuccess": "Login successful", + "subjectLoginFailed": "Login failed", + "titleOutboundDown": "Outbound DOWN", + "titleOutboundUp": "Outbound UP", + "titleXrayCrash": "Xray CRASHED", + "titleCPUHigh": "CPU high", + "titleLoginSuccess": "Login successful", + "titleLoginFailed": "Login failed", + "labelStatus": "Status", + "labelOutbound": "Outbound", + "labelNode": "Node", + "labelError": "Error", + "labelDelay": "Delay", + "labelDetail": "Detail", + "labelUsername": "Username", + "labelIP": "IP", + "labelReason": "Reason", + "labelSource": "Source", + "labelTime": "Time", + "statusCrashed": "CRASHED", + "statusRunning": "Running", + "statusHigh": "HIGH", + "statusSuccess": "SUCCESS", + "statusFailed": "FAILED", + "statusDown": "DOWN", + "statusUp": "UP" } -} +} \ No newline at end of file diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 6134d6954..b5e67ae42 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -1200,7 +1200,69 @@ "userPassMustBeNotEmpty": "El nuevo nombre de usuario y la nueva contraseña no pueden estar vacíos", "getOutboundTrafficError": "Error al obtener el tráfico saliente", "resetOutboundTrafficError": "Error al reiniciar el tráfico saliente" - } + }, + "emailNotifications": "Notificaciones", + "emailSettings": "Correo", + "eventCPUHigh": "CPU alta (%)", + "eventGroupOutbound": "Saliente", + "eventGroupSecurity": "Seguridad", + "eventGroupSystem": "Sistema", + "eventGroupXray": "Núcleo de Xray", + "eventLoginAttempt": "Intento de inicio de sesión", + "eventOutboundDown": "Caído", + "eventOutboundUp": "Activo", + "eventXrayCrash": "Caída", + "requestFailed": "La solicitud falló", + "smtpEnable": "Activar notificaciones por correo", + "smtpEnableDesc": "Activar notificaciones por correo mediante SMTP", + "smtpEncryption": "Cifrado", + "smtpEncryptionDesc": "Método de cifrado de la conexión SMTP", + "smtpEncryptionNone": "Ninguno (texto sin cifrar)", + "smtpEncryptionStartTLS": "STARTTLS", + "smtpEncryptionTLS": "TLS (implícito)", + "smtpEventBusNotify": "Notificaciones por correo de eventos", + "smtpEventBusNotifyDesc": "Seleccione qué eventos generan notificaciones por correo", + "smtpHost": "Servidor SMTP", + "smtpHostDesc": "Nombre del servidor SMTP (p. ej. smtp.gmail.com)", + "smtpHostNotConfigured": "Servidor SMTP no configurado", + "smtpNoRecipients": "No hay destinatarios configurados", + "smtpNotInitialized": "SMTP no inicializado", + "smtpPassword": "Contraseña SMTP", + "smtpPasswordDesc": "Contraseña de autenticación SMTP", + "smtpPort": "Puerto SMTP", + "smtpPortDesc": "Puerto del servidor SMTP (predeterminado: 587)", + "smtpSettings": "Configuración de SMTP", + "smtpStageAuth": "Autenticación", + "smtpStageConnect": "Conexión", + "smtpStageSend": "Envío", + "smtpTestSuccess": "Correo de prueba enviado correctamente", + "smtpTo": "Destinatarios", + "smtpToDesc": "Direcciones de correo de los destinatarios separadas por comas", + "smtpUsername": "Usuario SMTP", + "smtpUsernameDesc": "Usuario de autenticación SMTP", + "telegramTokenConfigured": "Configurado; deje en blanco para mantener el token actual.", + "telegramTokenPlaceholder": "Configurado: introduzca un nuevo token para reemplazarlo", + "testSmtp": "Enviar correo de prueba", + "testTgBot": "Enviar mensaje de prueba", + "tgBotNotEnabled": "El bot de Telegram no está activado", + "tgBotNotRunning": "El bot de Telegram no está en ejecución", + "tgEventBusNotify": "Notificaciones de Telegram de eventos", + "tgEventBusNotifyDesc": "Seleccione qué eventos generan notificaciones de Telegram", + "tgTestFailed": "La prueba de Telegram falló", + "tgTestSuccess": "Mensaje de prueba enviado a Telegram", + "smtpErrorAuth": "Error de autenticación: compruebe el usuario y la contraseña", + "smtpErrorStarttls": "El servidor requiere STARTTLS: cambie el tipo de cifrado", + "smtpErrorTls": "El servidor requiere TLS: cambie el tipo de cifrado", + "smtpErrorRefused": "Conexión rechazada: compruebe el servidor y el puerto", + "smtpErrorTimeout": "Tiempo de conexión agotado: servidor inaccesible", + "smtpErrorRelay": "El servidor rechaza el envío desde esta dirección", + "smtpErrorEof": "Conexión cerrada por el servidor", + "smtpErrorUnknown": "Error de SMTP: {{ .Error }}", + "eventGroupNode": "Nodos", + "eventNodeDown": "Caído", + "eventNodeUp": "Activo", + "smtpPasswordConfigured": "Configurada; deje en blanco para mantener la contraseña actual.", + "smtpPasswordPlaceholder": "Configurada: introduzca una nueva contraseña para reemplazarla" }, "xray": { "title": "Xray Configuración", @@ -1703,7 +1765,18 @@ "AreYouSure": "¿Estás seguro? 🤔", "SuccessResetTraffic": "📧 Correo: {{ .ClientEmail }}\n🏁 Resultado: ✅ Éxito", "FailedResetTraffic": "📧 Correo: {{ .ClientEmail }}\n🏁 Resultado: ❌ Fallido \n\n🛠️ Error: [ {{ .ErrorMessage }} ]", - "FinishProcess": "🔚 Proceso de reinicio de tráfico finalizado para todos los clientes." + "FinishProcess": "🔚 Proceso de reinicio de tráfico finalizado para todos los clientes.", + "eventCPUHigh": "CPU alta", + "eventCPUHighDetail": "CPU: {{ .Detail }}", + "eventDelayDetail": "Retardo: {{ .Delay }} ms", + "eventErrorDetail": "Error: {{ .Error }}", + "eventLoginFallback": "Inicio de sesión fallido desde {{ .Source }}", + "eventOutboundDown": "El saliente {{ .Tag }} está CAÍDO", + "eventOutboundUp": "El saliente {{ .Tag }} está ACTIVO", + "eventXrayCrash": "Xray se ha BLOQUEADO", + "eventXrayCrashError": "Error: {{ .Error }}", + "eventNodeDown": "El nodo {{ .Name }} está CAÍDO", + "eventNodeUp": "El nodo {{ .Name }} está ACTIVO" }, "buttons": { "closeKeyboard": "❌ Cerrar Teclado", @@ -1773,5 +1846,57 @@ "chooseClient": "Elige un Cliente para Inbound {{ .Inbound }}", "chooseInbound": "Elige un Inbound" } + }, + "email": { + "labelDelay": "Retardo", + "labelDetail": "Detalle", + "labelError": "Error", + "labelIP": "IP", + "labelOutbound": "Saliente", + "labelReason": "Motivo", + "labelSource": "Origen", + "labelStatus": "Estado", + "labelTime": "Hora", + "labelUsername": "Usuario", + "statusBanned": "BANNED", + "statusCrashed": "BLOQUEADO", + "statusDown": "CAÍDO", + "statusFailed": "FALLIDO", + "statusFull": "FULL", + "statusHigh": "ALTA", + "statusOffline": "OFFLINE", + "statusOnline": "ONLINE", + "statusRunning": "En ejecución", + "statusSuccess": "CORRECTO", + "statusUp": "ACTIVO", + "statusXrayDown": "Xray DOWN", + "statusXrayUp": "Xray UP", + "subjectCPUHigh": "CPU alta", + "subjectDiskFull": "Disk full", + "subjectIPBanned": "IP banned: {{ .IP }}", + "subjectLoginFailed": "Inicio de sesión fallido", + "subjectLoginSuccess": "Inicio de sesión correcto", + "subjectNodeOffline": "Node {{ .Node }} is OFFLINE", + "subjectNodeOnline": "Node {{ .Node }} is ONLINE", + "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN", + "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP", + "subjectOutboundDown": "El saliente {{ .Tag }} está CAÍDO", + "subjectOutboundUp": "El saliente {{ .Tag }} está ACTIVO", + "subjectXrayCrash": "Xray se ha BLOQUEADO", + "subjectXrayUp": "Xray is UP", + "titleCPUHigh": "CPU alta", + "titleDiskFull": "Disk full", + "titleIPBanned": "IP banned", + "titleLoginFailed": "Inicio de sesión fallido", + "titleLoginSuccess": "Inicio de sesión correcto", + "titleNodeOffline": "Node OFFLINE", + "titleNodeOnline": "Node ONLINE", + "titleNodeXrayDown": "Node Xray DOWN", + "titleNodeXrayUp": "Node Xray UP", + "titleOutboundDown": "Saliente CAÍDO", + "titleOutboundUp": "Saliente ACTIVO", + "titleXrayCrash": "Xray se ha BLOQUEADO", + "titleXrayUp": "Xray UP", + "labelNode": "Nodo" } } \ No newline at end of file diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index aab76ffc2..bf446a9b2 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -1200,7 +1200,69 @@ "userPassMustBeNotEmpty": "نام‌کاربری یا رمزعبور جدید خالی‌است", "getOutboundTrafficError": "خطا در دریافت ترافیک خروجی", "resetOutboundTrafficError": "خطا در بازنشانی ترافیک خروجی" - } + }, + "emailNotifications": "اعلان‌ها", + "emailSettings": "ایمیل", + "eventCPUHigh": "بالا بودن CPU (٪)", + "eventGroupOutbound": "خروجی", + "eventGroupSecurity": "امنیت", + "eventGroupSystem": "سیستم", + "eventGroupXray": "هسته Xray", + "eventLoginAttempt": "تلاش برای ورود", + "eventOutboundDown": "قطع", + "eventOutboundUp": "وصل", + "eventXrayCrash": "کرش", + "requestFailed": "درخواست ناموفق بود", + "smtpEnable": "فعال‌سازی اعلان‌های ایمیلی", + "smtpEnableDesc": "فعال‌سازی اعلان‌های ایمیلی از طریق SMTP", + "smtpEncryption": "رمزنگاری", + "smtpEncryptionDesc": "روش رمزنگاری اتصال SMTP", + "smtpEncryptionNone": "هیچ‌کدام (متن ساده)", + "smtpEncryptionStartTLS": "STARTTLS", + "smtpEncryptionTLS": "TLS (ضمنی)", + "smtpEventBusNotify": "اعلان‌های رویداد ایمیلی", + "smtpEventBusNotifyDesc": "انتخاب کنید کدام رویدادها اعلان ایمیلی را فعال می‌کنند", + "smtpHost": "میزبان SMTP", + "smtpHostDesc": "نام میزبان سرور SMTP (مثلاً smtp.gmail.com)", + "smtpHostNotConfigured": "میزبان SMTP پیکربندی نشده است", + "smtpNoRecipients": "هیچ گیرنده‌ای پیکربندی نشده است", + "smtpNotInitialized": "SMTP مقداردهی اولیه نشده است", + "smtpPassword": "رمز عبور SMTP", + "smtpPasswordDesc": "رمز عبور احراز هویت SMTP", + "smtpPort": "پورت SMTP", + "smtpPortDesc": "پورت سرور SMTP (پیش‌فرض: ۵۸۷)", + "smtpSettings": "تنظیمات SMTP", + "smtpStageAuth": "احراز هویت", + "smtpStageConnect": "اتصال", + "smtpStageSend": "ارسال", + "smtpTestSuccess": "ایمیل آزمایشی با موفقیت ارسال شد", + "smtpTo": "گیرندگان", + "smtpToDesc": "آدرس‌های ایمیل گیرندگان، جداشده با کاما", + "smtpUsername": "نام‌کاربری SMTP", + "smtpUsernameDesc": "نام‌کاربری احراز هویت SMTP", + "telegramTokenConfigured": "پیکربندی شده؛ برای حفظ توکن فعلی خالی بگذارید.", + "telegramTokenPlaceholder": "پیکربندی شده - برای جایگزینی، توکن جدید وارد کنید", + "testSmtp": "ارسال ایمیل آزمایشی", + "testTgBot": "ارسال پیام آزمایشی", + "tgBotNotEnabled": "ربات تلگرام فعال نیست", + "tgBotNotRunning": "ربات تلگرام در حال اجرا نیست", + "tgEventBusNotify": "اعلان‌های رویداد تلگرام", + "tgEventBusNotifyDesc": "انتخاب کنید کدام رویدادها اعلان تلگرام را فعال می‌کنند", + "tgTestFailed": "آزمایش تلگرام ناموفق بود", + "tgTestSuccess": "پیام آزمایشی به تلگرام ارسال شد", + "smtpErrorAuth": "احراز هویت ناموفق بود — نام‌کاربری و رمز عبور را بررسی کنید", + "smtpErrorStarttls": "سرور به STARTTLS نیاز دارد — نوع رمزنگاری را تغییر دهید", + "smtpErrorTls": "سرور به TLS نیاز دارد — نوع رمزنگاری را تغییر دهید", + "smtpErrorRefused": "اتصال رد شد — میزبان و پورت را بررسی کنید", + "smtpErrorTimeout": "مهلت اتصال به پایان رسید — میزبان در دسترس نیست", + "smtpErrorRelay": "سرور ارسال از این آدرس را رد می‌کند", + "smtpErrorEof": "اتصال توسط سرور بسته شد", + "smtpErrorUnknown": "خطای SMTP: {{ .Error }}", + "eventGroupNode": "نودها", + "eventNodeDown": "قطع", + "eventNodeUp": "وصل", + "smtpPasswordConfigured": "پیکربندی شده؛ برای حفظ رمز عبور فعلی خالی بگذارید.", + "smtpPasswordPlaceholder": "پیکربندی شده - برای جایگزینی، رمز عبور جدید وارد کنید" }, "xray": { "title": "پیکربندی ایکس‌ری", @@ -1703,7 +1765,18 @@ "AreYouSure": "مطمئنی؟ 🤔", "SuccessResetTraffic": "📧 ایمیل: {{ .ClientEmail }}\n🏁 نتیجه: ✅ موفقیت‌آمیز", "FailedResetTraffic": "📧 ایمیل: {{ .ClientEmail }}\n🏁 نتیجه: ❌ ناموفق \n\n🛠️ خطا: [ {{ .ErrorMessage }} ]", - "FinishProcess": "🔚 فرآیند بازنشانی ترافیک برای همه مشتریان به پایان رسید." + "FinishProcess": "🔚 فرآیند بازنشانی ترافیک برای همه مشتریان به پایان رسید.", + "eventCPUHigh": "بالا بودن CPU", + "eventCPUHighDetail": "CPU: {{ .Detail }}", + "eventDelayDetail": "تأخیر: {{ .Delay }} میلی‌ثانیه", + "eventErrorDetail": "خطا: {{ .Error }}", + "eventLoginFallback": "ورود ناموفق از {{ .Source }}", + "eventOutboundDown": "خروجی {{ .Tag }} قطع است", + "eventOutboundUp": "خروجی {{ .Tag }} وصل است", + "eventXrayCrash": "Xray کرش کرد", + "eventXrayCrashError": "خطا: {{ .Error }}", + "eventNodeDown": "نود {{ .Name }} قطع است", + "eventNodeUp": "نود {{ .Name }} وصل است" }, "buttons": { "closeKeyboard": "❌ بستن کیبورد", @@ -1773,5 +1846,57 @@ "chooseClient": "یک مشتری برای ورودی {{ .Inbound }} انتخاب کنید", "chooseInbound": "یک ورودی انتخاب کنید" } + }, + "email": { + "labelDelay": "تأخیر", + "labelDetail": "جزئیات", + "labelError": "خطا", + "labelIP": "IP", + "labelOutbound": "خروجی", + "labelReason": "دلیل", + "labelSource": "مبدأ", + "labelStatus": "وضعیت", + "labelTime": "زمان", + "labelUsername": "نام‌کاربری", + "statusBanned": "BANNED", + "statusCrashed": "کرش کرد", + "statusDown": "قطع", + "statusFailed": "ناموفق", + "statusFull": "FULL", + "statusHigh": "بالا", + "statusOffline": "OFFLINE", + "statusOnline": "ONLINE", + "statusRunning": "در حال اجرا", + "statusSuccess": "موفق", + "statusUp": "وصل", + "statusXrayDown": "Xray DOWN", + "statusXrayUp": "Xray UP", + "subjectCPUHigh": "بالا بودن CPU", + "subjectDiskFull": "Disk full", + "subjectIPBanned": "IP banned: {{ .IP }}", + "subjectLoginFailed": "ورود ناموفق", + "subjectLoginSuccess": "ورود موفق", + "subjectNodeOffline": "Node {{ .Node }} is OFFLINE", + "subjectNodeOnline": "Node {{ .Node }} is ONLINE", + "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN", + "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP", + "subjectOutboundDown": "خروجی {{ .Tag }} قطع است", + "subjectOutboundUp": "خروجی {{ .Tag }} وصل است", + "subjectXrayCrash": "Xray کرش کرد", + "subjectXrayUp": "Xray is UP", + "titleCPUHigh": "بالا بودن CPU", + "titleDiskFull": "Disk full", + "titleIPBanned": "IP banned", + "titleLoginFailed": "ورود ناموفق", + "titleLoginSuccess": "ورود موفق", + "titleNodeOffline": "Node OFFLINE", + "titleNodeOnline": "Node ONLINE", + "titleNodeXrayDown": "Node Xray DOWN", + "titleNodeXrayUp": "Node Xray UP", + "titleOutboundDown": "خروجی قطع شد", + "titleOutboundUp": "خروجی وصل شد", + "titleXrayCrash": "Xray کرش کرد", + "titleXrayUp": "Xray UP", + "labelNode": "نود" } -} +} \ No newline at end of file diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index b56ce10a4..68a0600bc 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -1200,7 +1200,69 @@ "userPassMustBeNotEmpty": "Username dan password baru tidak boleh kosong", "getOutboundTrafficError": "Gagal mendapatkan lalu lintas keluar", "resetOutboundTrafficError": "Gagal mereset lalu lintas keluar" - } + }, + "emailNotifications": "Notifikasi", + "emailSettings": "Email", + "eventCPUHigh": "CPU tinggi (%)", + "eventGroupOutbound": "Outbound", + "eventGroupSecurity": "Keamanan", + "eventGroupSystem": "Sistem", + "eventGroupXray": "Xray Core", + "eventLoginAttempt": "Percobaan masuk", + "eventOutboundDown": "Mati", + "eventOutboundUp": "Aktif", + "eventXrayCrash": "Crash", + "requestFailed": "Permintaan gagal", + "smtpEnable": "Aktifkan Notifikasi Email", + "smtpEnableDesc": "Aktifkan notifikasi email melalui SMTP", + "smtpEncryption": "Enkripsi", + "smtpEncryptionDesc": "Metode enkripsi koneksi SMTP", + "smtpEncryptionNone": "Tidak ada (teks biasa)", + "smtpEncryptionStartTLS": "STARTTLS", + "smtpEncryptionTLS": "TLS (implisit)", + "smtpEventBusNotify": "Notifikasi Peristiwa Email", + "smtpEventBusNotifyDesc": "Pilih peristiwa yang memicu notifikasi email", + "smtpHost": "Host SMTP", + "smtpHostDesc": "Nama host server SMTP (mis. smtp.gmail.com)", + "smtpHostNotConfigured": "Host SMTP belum dikonfigurasi", + "smtpNoRecipients": "Tidak ada penerima yang dikonfigurasi", + "smtpNotInitialized": "SMTP belum diinisialisasi", + "smtpPassword": "Kata Sandi SMTP", + "smtpPasswordDesc": "Kata sandi autentikasi SMTP", + "smtpPort": "Port SMTP", + "smtpPortDesc": "Port server SMTP (bawaan: 587)", + "smtpSettings": "Pengaturan SMTP", + "smtpStageAuth": "Autentikasi", + "smtpStageConnect": "Koneksi", + "smtpStageSend": "Kirim", + "smtpTestSuccess": "Email uji berhasil dikirim", + "smtpTo": "Penerima", + "smtpToDesc": "Alamat email penerima dipisahkan dengan koma", + "smtpUsername": "Nama Pengguna SMTP", + "smtpUsernameDesc": "Nama pengguna autentikasi SMTP", + "telegramTokenConfigured": "Terkonfigurasi; kosongkan untuk mempertahankan token saat ini.", + "telegramTokenPlaceholder": "Terkonfigurasi - masukkan token baru untuk mengganti", + "testSmtp": "Kirim Email Uji", + "testTgBot": "Kirim Pesan Uji", + "tgBotNotEnabled": "Bot Telegram tidak aktif", + "tgBotNotRunning": "Bot Telegram tidak berjalan", + "tgEventBusNotify": "Notifikasi Peristiwa Telegram", + "tgEventBusNotifyDesc": "Pilih peristiwa yang memicu notifikasi Telegram", + "tgTestFailed": "Uji Telegram gagal", + "tgTestSuccess": "Pesan uji terkirim ke Telegram", + "smtpErrorAuth": "Autentikasi gagal — periksa nama pengguna dan kata sandi", + "smtpErrorStarttls": "Server memerlukan STARTTLS — ubah jenis enkripsi", + "smtpErrorTls": "Server memerlukan TLS — ubah jenis enkripsi", + "smtpErrorRefused": "Koneksi ditolak — periksa host dan port", + "smtpErrorTimeout": "Koneksi waktu habis — host tidak dapat dijangkau", + "smtpErrorRelay": "Server menolak pengiriman dari alamat ini", + "smtpErrorEof": "Koneksi ditutup oleh server", + "smtpErrorUnknown": "Kesalahan SMTP: {{ .Error }}", + "eventGroupNode": "Node", + "eventNodeDown": "Mati", + "eventNodeUp": "Aktif", + "smtpPasswordConfigured": "Terkonfigurasi; kosongkan untuk mempertahankan kata sandi saat ini.", + "smtpPasswordPlaceholder": "Terkonfigurasi - masukkan kata sandi baru untuk mengganti" }, "xray": { "title": "Konfigurasi Xray", @@ -1703,7 +1765,18 @@ "AreYouSure": "Apakah kamu yakin? 🤔", "SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Hasil: ✅ Berhasil", "FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Hasil: ❌ Gagal \n\n🛠️ Kesalahan: [ {{ .ErrorMessage }} ]", - "FinishProcess": "🔚 Proses reset traffic selesai untuk semua klien." + "FinishProcess": "🔚 Proses reset traffic selesai untuk semua klien.", + "eventCPUHigh": "CPU tinggi", + "eventCPUHighDetail": "CPU: {{ .Detail }}", + "eventDelayDetail": "Penundaan: {{ .Delay }}ms", + "eventErrorDetail": "Kesalahan: {{ .Error }}", + "eventLoginFallback": "Gagal masuk dari {{ .Source }}", + "eventOutboundDown": "Outbound {{ .Tag }} MATI", + "eventOutboundUp": "Outbound {{ .Tag }} AKTIF", + "eventXrayCrash": "Xray CRASH", + "eventXrayCrashError": "Kesalahan: {{ .Error }}", + "eventNodeDown": "Node {{ .Name }} MATI", + "eventNodeUp": "Node {{ .Name }} AKTIF" }, "buttons": { "closeKeyboard": "❌ Tutup Papan Ketik", @@ -1773,5 +1846,57 @@ "chooseClient": "Pilih Klien untuk Inbound {{ .Inbound }}", "chooseInbound": "Pilih Inbound" } + }, + "email": { + "labelDelay": "Penundaan", + "labelDetail": "Detail", + "labelError": "Kesalahan", + "labelIP": "IP", + "labelOutbound": "Outbound", + "labelReason": "Alasan", + "labelSource": "Sumber", + "labelStatus": "Status", + "labelTime": "Waktu", + "labelUsername": "Nama Pengguna", + "statusBanned": "BANNED", + "statusCrashed": "CRASH", + "statusDown": "MATI", + "statusFailed": "GAGAL", + "statusFull": "FULL", + "statusHigh": "TINGGI", + "statusOffline": "OFFLINE", + "statusOnline": "ONLINE", + "statusRunning": "Berjalan", + "statusSuccess": "BERHASIL", + "statusUp": "AKTIF", + "statusXrayDown": "Xray DOWN", + "statusXrayUp": "Xray UP", + "subjectCPUHigh": "CPU tinggi", + "subjectDiskFull": "Disk full", + "subjectIPBanned": "IP banned: {{ .IP }}", + "subjectLoginFailed": "Gagal masuk", + "subjectLoginSuccess": "Berhasil masuk", + "subjectNodeOffline": "Node {{ .Node }} is OFFLINE", + "subjectNodeOnline": "Node {{ .Node }} is ONLINE", + "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN", + "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP", + "subjectOutboundDown": "Outbound {{ .Tag }} MATI", + "subjectOutboundUp": "Outbound {{ .Tag }} AKTIF", + "subjectXrayCrash": "Xray CRASH", + "subjectXrayUp": "Xray is UP", + "titleCPUHigh": "CPU tinggi", + "titleDiskFull": "Disk full", + "titleIPBanned": "IP banned", + "titleLoginFailed": "Gagal masuk", + "titleLoginSuccess": "Berhasil masuk", + "titleNodeOffline": "Node OFFLINE", + "titleNodeOnline": "Node ONLINE", + "titleNodeXrayDown": "Node Xray DOWN", + "titleNodeXrayUp": "Node Xray UP", + "titleOutboundDown": "Outbound MATI", + "titleOutboundUp": "Outbound AKTIF", + "titleXrayCrash": "Xray CRASH", + "titleXrayUp": "Xray UP", + "labelNode": "Node" } } \ No newline at end of file diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 2cc332ce3..ef76b95e7 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -1200,7 +1200,69 @@ "userPassMustBeNotEmpty": "新しいユーザー名と新しいパスワードは空にできません", "getOutboundTrafficError": "送信トラフィックの取得エラー", "resetOutboundTrafficError": "送信トラフィックのリセットエラー" - } + }, + "emailNotifications": "通知", + "emailSettings": "メール", + "eventCPUHigh": "CPU高負荷(%)", + "eventGroupOutbound": "アウトバウンド", + "eventGroupSecurity": "セキュリティ", + "eventGroupSystem": "システム", + "eventGroupXray": "Xrayコア", + "eventLoginAttempt": "ログイン試行", + "eventOutboundDown": "ダウン", + "eventOutboundUp": "アップ", + "eventXrayCrash": "クラッシュ", + "requestFailed": "リクエストに失敗しました", + "smtpEnable": "メール通知を有効化", + "smtpEnableDesc": "SMTP経由のメール通知を有効にします", + "smtpEncryption": "暗号化", + "smtpEncryptionDesc": "SMTP接続の暗号化方式", + "smtpEncryptionNone": "なし(平文)", + "smtpEncryptionStartTLS": "STARTTLS", + "smtpEncryptionTLS": "TLS(暗黙的)", + "smtpEventBusNotify": "メールイベント通知", + "smtpEventBusNotifyDesc": "メール通知をトリガーするイベントを選択してください", + "smtpHost": "SMTPホスト", + "smtpHostDesc": "SMTPサーバーのホスト名(例: smtp.gmail.com)", + "smtpHostNotConfigured": "SMTPホストが設定されていません", + "smtpNoRecipients": "受信者が設定されていません", + "smtpNotInitialized": "SMTPが初期化されていません", + "smtpPassword": "SMTPパスワード", + "smtpPasswordDesc": "SMTP認証用のパスワード", + "smtpPort": "SMTPポート", + "smtpPortDesc": "SMTPサーバーのポート(既定値: 587)", + "smtpSettings": "SMTP設定", + "smtpStageAuth": "認証", + "smtpStageConnect": "接続", + "smtpStageSend": "送信", + "smtpTestSuccess": "テストメールを正常に送信しました", + "smtpTo": "受信者", + "smtpToDesc": "受信者のメールアドレス(カンマ区切り)", + "smtpUsername": "SMTPユーザー名", + "smtpUsernameDesc": "SMTP認証用のユーザー名", + "telegramTokenConfigured": "設定済み。現在のトークンを維持する場合は空欄のままにしてください。", + "telegramTokenPlaceholder": "設定済み - 置き換えるには新しいトークンを入力してください", + "testSmtp": "テストメールを送信", + "testTgBot": "テストメッセージを送信", + "tgBotNotEnabled": "Telegramボットが有効になっていません", + "tgBotNotRunning": "Telegramボットが実行されていません", + "tgEventBusNotify": "Telegramイベント通知", + "tgEventBusNotifyDesc": "Telegram通知をトリガーするイベントを選択してください", + "tgTestFailed": "Telegramのテストに失敗しました", + "tgTestSuccess": "Telegramにテストメッセージを送信しました", + "smtpErrorAuth": "認証に失敗しました — ユーザー名とパスワードを確認してください", + "smtpErrorStarttls": "サーバーはSTARTTLSを要求しています — 暗号化方式を変更してください", + "smtpErrorTls": "サーバーはTLSを要求しています — 暗号化方式を変更してください", + "smtpErrorRefused": "接続が拒否されました — ホストとポートを確認してください", + "smtpErrorTimeout": "接続がタイムアウトしました — ホストに到達できません", + "smtpErrorRelay": "サーバーはこのアドレスからの送信を拒否しています", + "smtpErrorEof": "サーバーによって接続が閉じられました", + "smtpErrorUnknown": "SMTPエラー: {{ .Error }}", + "eventGroupNode": "ノード", + "eventNodeDown": "ダウン", + "eventNodeUp": "アップ", + "smtpPasswordConfigured": "設定済み。現在のパスワードを維持する場合は空欄のままにしてください。", + "smtpPasswordPlaceholder": "設定済み - 置き換えるには新しいパスワードを入力してください" }, "xray": { "title": "Xray 設定", @@ -1703,7 +1765,18 @@ "AreYouSure": "本当にいいですか?🤔", "SuccessResetTraffic": "📧 メール: {{ .ClientEmail }}\n🏁 結果: ✅ 成功", "FailedResetTraffic": "📧 メール: {{ .ClientEmail }}\n🏁 結果: ❌ 失敗 \n\n🛠️ エラー: [ {{ .ErrorMessage }} ]", - "FinishProcess": "🔚 すべてのクライアントのトラフィックリセットが完了しました。" + "FinishProcess": "🔚 すべてのクライアントのトラフィックリセットが完了しました。", + "eventCPUHigh": "CPU高負荷", + "eventCPUHighDetail": "CPU: {{ .Detail }}", + "eventDelayDetail": "遅延: {{ .Delay }}ms", + "eventErrorDetail": "エラー: {{ .Error }}", + "eventLoginFallback": "{{ .Source }} からのログインに失敗しました", + "eventOutboundDown": "アウトバウンド {{ .Tag }} がダウンしています", + "eventOutboundUp": "アウトバウンド {{ .Tag }} が復旧しました", + "eventXrayCrash": "Xrayがクラッシュしました", + "eventXrayCrashError": "エラー: {{ .Error }}", + "eventNodeDown": "ノード {{ .Name }} がダウンしています", + "eventNodeUp": "ノード {{ .Name }} が復旧しました" }, "buttons": { "closeKeyboard": "❌ キーボードを閉じる", @@ -1773,5 +1846,57 @@ "chooseClient": "インバウンド {{ .Inbound }} のクライアントを選択", "chooseInbound": "インバウンドを選択" } + }, + "email": { + "labelDelay": "遅延", + "labelDetail": "詳細", + "labelError": "エラー", + "labelIP": "IP", + "labelOutbound": "アウトバウンド", + "labelReason": "理由", + "labelSource": "送信元", + "labelStatus": "ステータス", + "labelTime": "時刻", + "labelUsername": "ユーザー名", + "statusBanned": "BANNED", + "statusCrashed": "クラッシュ", + "statusDown": "ダウン", + "statusFailed": "失敗", + "statusFull": "FULL", + "statusHigh": "高負荷", + "statusOffline": "OFFLINE", + "statusOnline": "ONLINE", + "statusRunning": "実行中", + "statusSuccess": "成功", + "statusUp": "アップ", + "statusXrayDown": "Xray DOWN", + "statusXrayUp": "Xray UP", + "subjectCPUHigh": "CPU高負荷", + "subjectDiskFull": "Disk full", + "subjectIPBanned": "IP banned: {{ .IP }}", + "subjectLoginFailed": "ログイン失敗", + "subjectLoginSuccess": "ログイン成功", + "subjectNodeOffline": "Node {{ .Node }} is OFFLINE", + "subjectNodeOnline": "Node {{ .Node }} is ONLINE", + "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN", + "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP", + "subjectOutboundDown": "アウトバウンド {{ .Tag }} がダウンしています", + "subjectOutboundUp": "アウトバウンド {{ .Tag }} が復旧しました", + "subjectXrayCrash": "Xrayがクラッシュしました", + "subjectXrayUp": "Xray is UP", + "titleCPUHigh": "CPU高負荷", + "titleDiskFull": "Disk full", + "titleIPBanned": "IP banned", + "titleLoginFailed": "ログイン失敗", + "titleLoginSuccess": "ログイン成功", + "titleNodeOffline": "Node OFFLINE", + "titleNodeOnline": "Node ONLINE", + "titleNodeXrayDown": "Node Xray DOWN", + "titleNodeXrayUp": "Node Xray UP", + "titleOutboundDown": "アウトバウンド ダウン", + "titleOutboundUp": "アウトバウンド 復旧", + "titleXrayCrash": "Xrayがクラッシュしました", + "titleXrayUp": "Xray UP", + "labelNode": "ノード" } } \ No newline at end of file diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 921722b27..a34010342 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -1200,7 +1200,69 @@ "userPassMustBeNotEmpty": "O novo nome de usuário e senha não podem estar vazios", "getOutboundTrafficError": "Erro ao obter tráfego de saída", "resetOutboundTrafficError": "Erro ao redefinir tráfego de saída" - } + }, + "emailNotifications": "Notificações", + "emailSettings": "E-mail", + "eventCPUHigh": "CPU alta (%)", + "eventGroupOutbound": "Outbound", + "eventGroupSecurity": "Segurança", + "eventGroupSystem": "Sistema", + "eventGroupXray": "Núcleo Xray", + "eventLoginAttempt": "Tentativa de login", + "eventOutboundDown": "Inativo", + "eventOutboundUp": "Ativo", + "eventXrayCrash": "Falha", + "requestFailed": "Falha na requisição", + "smtpEnable": "Ativar notificações por e-mail", + "smtpEnableDesc": "Ativar notificações por e-mail via SMTP", + "smtpEncryption": "Criptografia", + "smtpEncryptionDesc": "Método de criptografia da conexão SMTP", + "smtpEncryptionNone": "Nenhuma (texto puro)", + "smtpEncryptionStartTLS": "STARTTLS", + "smtpEncryptionTLS": "TLS (implícito)", + "smtpEventBusNotify": "Notificações de eventos por e-mail", + "smtpEventBusNotifyDesc": "Selecione quais eventos disparam notificações por e-mail", + "smtpHost": "Servidor SMTP", + "smtpHostDesc": "Nome do host do servidor SMTP (ex.: smtp.gmail.com)", + "smtpHostNotConfigured": "Servidor SMTP não configurado", + "smtpNoRecipients": "Nenhum destinatário configurado", + "smtpNotInitialized": "SMTP não inicializado", + "smtpPassword": "Senha SMTP", + "smtpPasswordDesc": "Senha para autenticação SMTP", + "smtpPort": "Porta SMTP", + "smtpPortDesc": "Porta do servidor SMTP (padrão: 587)", + "smtpSettings": "Configurações SMTP", + "smtpStageAuth": "Autenticação", + "smtpStageConnect": "Conexão", + "smtpStageSend": "Envio", + "smtpTestSuccess": "E-mail de teste enviado com sucesso", + "smtpTo": "Destinatários", + "smtpToDesc": "Endereços de e-mail dos destinatários separados por vírgula", + "smtpUsername": "Usuário SMTP", + "smtpUsernameDesc": "Nome de usuário para autenticação SMTP", + "telegramTokenConfigured": "Configurado; deixe em branco para manter o token atual.", + "telegramTokenPlaceholder": "Configurado - insira um novo token para substituir", + "testSmtp": "Enviar e-mail de teste", + "testTgBot": "Enviar mensagem de teste", + "tgBotNotEnabled": "O bot do Telegram não está ativado", + "tgBotNotRunning": "O bot do Telegram não está em execução", + "tgEventBusNotify": "Notificações de eventos no Telegram", + "tgEventBusNotifyDesc": "Selecione quais eventos disparam notificações no Telegram", + "tgTestFailed": "Falha no teste do Telegram", + "tgTestSuccess": "Mensagem de teste enviada ao Telegram", + "smtpErrorAuth": "Falha na autenticação — verifique o nome de usuário e a senha", + "smtpErrorStarttls": "O servidor requer STARTTLS — altere o tipo de criptografia", + "smtpErrorTls": "O servidor requer TLS — altere o tipo de criptografia", + "smtpErrorRefused": "Conexão recusada — verifique o host e a porta", + "smtpErrorTimeout": "Tempo de conexão esgotado — host inacessível", + "smtpErrorRelay": "O servidor rejeita o envio a partir deste endereço", + "smtpErrorEof": "Conexão encerrada pelo servidor", + "smtpErrorUnknown": "Erro de SMTP: {{ .Error }}", + "eventGroupNode": "Nós", + "eventNodeDown": "Inativo", + "eventNodeUp": "Ativo", + "smtpPasswordConfigured": "Configurada; deixe em branco para manter a senha atual.", + "smtpPasswordPlaceholder": "Configurada - insira uma nova senha para substituir" }, "xray": { "title": "Configurações Xray", @@ -1703,7 +1765,18 @@ "AreYouSure": "Você tem certeza? 🤔", "SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Resultado: ✅ Sucesso", "FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Resultado: ❌ Falhou \n\n🛠️ Erro: [ {{ .ErrorMessage }} ]", - "FinishProcess": "🔚 Processo de redefinição de tráfego concluído para todos os clientes." + "FinishProcess": "🔚 Processo de redefinição de tráfego concluído para todos os clientes.", + "eventCPUHigh": "CPU alta", + "eventCPUHighDetail": "CPU: {{ .Detail }}", + "eventDelayDetail": "Latência: {{ .Delay }}ms", + "eventErrorDetail": "Erro: {{ .Error }}", + "eventLoginFallback": "Falha de login a partir de {{ .Source }}", + "eventOutboundDown": "O outbound {{ .Tag }} está INATIVO", + "eventOutboundUp": "O outbound {{ .Tag }} está ATIVO", + "eventXrayCrash": "O Xray FALHOU", + "eventXrayCrashError": "Erro: {{ .Error }}", + "eventNodeDown": "O nó {{ .Name }} está INATIVO", + "eventNodeUp": "O nó {{ .Name }} está ATIVO" }, "buttons": { "closeKeyboard": "❌ Fechar teclado", @@ -1773,5 +1846,57 @@ "chooseClient": "Escolha um cliente para Inbound {{ .Inbound }}", "chooseInbound": "Escolha um Inbound" } + }, + "email": { + "labelDelay": "Latência", + "labelDetail": "Detalhe", + "labelError": "Erro", + "labelIP": "IP", + "labelOutbound": "Outbound", + "labelReason": "Motivo", + "labelSource": "Origem", + "labelStatus": "Status", + "labelTime": "Horário", + "labelUsername": "Nome de usuário", + "statusBanned": "BANNED", + "statusCrashed": "FALHOU", + "statusDown": "INATIVO", + "statusFailed": "FALHOU", + "statusFull": "FULL", + "statusHigh": "ALTA", + "statusOffline": "OFFLINE", + "statusOnline": "ONLINE", + "statusRunning": "Em execução", + "statusSuccess": "SUCESSO", + "statusUp": "ATIVO", + "statusXrayDown": "Xray DOWN", + "statusXrayUp": "Xray UP", + "subjectCPUHigh": "CPU alta", + "subjectDiskFull": "Disk full", + "subjectIPBanned": "IP banned: {{ .IP }}", + "subjectLoginFailed": "Falha de login", + "subjectLoginSuccess": "Login bem-sucedido", + "subjectNodeOffline": "Node {{ .Node }} is OFFLINE", + "subjectNodeOnline": "Node {{ .Node }} is ONLINE", + "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN", + "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP", + "subjectOutboundDown": "O outbound {{ .Tag }} está INATIVO", + "subjectOutboundUp": "O outbound {{ .Tag }} está ATIVO", + "subjectXrayCrash": "O Xray FALHOU", + "subjectXrayUp": "Xray is UP", + "titleCPUHigh": "CPU alta", + "titleDiskFull": "Disk full", + "titleIPBanned": "IP banned", + "titleLoginFailed": "Falha de login", + "titleLoginSuccess": "Login bem-sucedido", + "titleNodeOffline": "Node OFFLINE", + "titleNodeOnline": "Node ONLINE", + "titleNodeXrayDown": "Node Xray DOWN", + "titleNodeXrayUp": "Node Xray UP", + "titleOutboundDown": "Outbound INATIVO", + "titleOutboundUp": "Outbound ATIVO", + "titleXrayCrash": "O Xray FALHOU", + "titleXrayUp": "Xray UP", + "labelNode": "Nó" } } \ No newline at end of file diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index d294803d3..42b134247 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -1200,7 +1200,69 @@ "userPassMustBeNotEmpty": "Новое имя пользователя и новый пароль должны быть заполнены", "getOutboundTrafficError": "Ошибка получения трафика исходящего подключения", "resetOutboundTrafficError": "Ошибка сброса трафика исходящего подключения" - } + }, + "smtpSettings": "Настройки SMTP", + "smtpEnable": "Включить уведомления по Email", + "smtpEnableDesc": "Включить уведомления по email через SMTP", + "smtpHost": "SMTP хост", + "smtpHostDesc": "Имя хоста SMTP сервера (например smtp.gmail.com)", + "smtpPort": "SMTP порт", + "smtpPortDesc": "Порт SMTP сервера (по умолчанию: 587)", + "smtpUsername": "SMTP логин", + "smtpUsernameDesc": "Логин для аутентификации SMTP", + "smtpPassword": "SMTP пароль", + "smtpPasswordDesc": "Пароль для аутентификации SMTP", + "smtpTo": "Получатели", + "smtpToDesc": "Адреса получателей через запятую", + "emailSettings": "Email", + "emailNotifications": "Уведомления", + "smtpEventBusNotify": "Email уведомления о событиях", + "smtpEventBusNotifyDesc": "Выберите события для email уведомлений", + "tgEventBusNotify": "Telegram уведомления о событиях", + "tgEventBusNotifyDesc": "Выберите события для Telegram уведомлений", + "testSmtp": "Отправить тестовое письмо", + "testTgBot": "Отправить тестовое сообщение", + "eventGroupOutbound": "Исходящие", + "eventGroupXray": "Ядро Xray", + "eventGroupSystem": "Система", + "eventGroupSecurity": "Безопасность", + "eventOutboundDown": "Недоступен", + "eventOutboundUp": "Работает", + "eventXrayCrash": "Сбой", + "eventCPUHigh": "Превышение порога CPU (%)", + "requestFailed": "Запрос не удался", + "smtpEncryption": "Шифрование", + "smtpEncryptionDesc": "Метод шифрования SMTP соединения", + "smtpEncryptionNone": "Нет (открытый текст)", + "smtpEncryptionStartTLS": "STARTTLS", + "smtpEncryptionTLS": "TLS (неявное)", + "smtpStageConnect": "Подключение", + "smtpStageAuth": "Аутентификация", + "smtpStageSend": "Отправка", + "smtpTestSuccess": "Тестовое письмо отправлено успешно", + "smtpHostNotConfigured": "SMTP хост не настроен", + "smtpNoRecipients": "Получатели не настроены", + "eventLoginAttempt": "Попытка входа", + "telegramTokenConfigured": "Настроен; оставьте пустым для сохранения текущего токена.", + "telegramTokenPlaceholder": "Настроен - введите новый токен для замены", + "smtpNotInitialized": "SMTP не инициализирован", + "tgBotNotEnabled": "Telegram бот не включен", + "tgTestFailed": "Тест Telegram не удался", + "tgTestSuccess": "Тестовое сообщение отправлено в Telegram", + "tgBotNotRunning": "Telegram бот не запущен", + "smtpErrorAuth": "Ошибка аутентификации — проверьте логин и пароль", + "smtpErrorStarttls": "Сервер требует STARTTLS — измените тип шифрования", + "smtpErrorTls": "Сервер требует TLS — измените тип шифрования", + "smtpErrorRefused": "Соединение отклонено — проверьте хост и порт", + "smtpErrorTimeout": "Таймаут соединения — хост недоступен", + "smtpErrorRelay": "Сервер отклоняет отправку с этого адреса", + "smtpErrorEof": "Соединение закрыто сервером", + "smtpErrorUnknown": "Ошибка SMTP: {{ .Error }}", + "eventGroupNode": "Узлы", + "eventNodeDown": "Недоступен", + "eventNodeUp": "В сети", + "smtpPasswordConfigured": "Настроен; оставьте пустым для сохранения текущего пароля.", + "smtpPasswordPlaceholder": "Настроен - введите новый пароль для замены" }, "xray": { "title": "Настройки Xray", @@ -1703,7 +1765,18 @@ "AreYouSure": "Вы уверены? 🤔", "SuccessResetTraffic": "📧 Почта: {{ .ClientEmail }}\n🏁 Результат: ✅ Успешно", "FailedResetTraffic": "📧 Почта: {{ .ClientEmail }}\n🏁 Результат: ❌ Неудача \n\n🛠️ Ошибка: [ {{ .ErrorMessage }} ]", - "FinishProcess": "🔚 Сброс трафика завершён для всех клиентов." + "FinishProcess": "🔚 Сброс трафика завершён для всех клиентов.", + "eventOutboundDown": "Исходящее подключение {{ .Tag }} НЕДОСТУПНО", + "eventOutboundUp": "Исходящее подключение {{ .Tag }} РАБОТАЕТ", + "eventXrayCrash": "Сбой Xray", + "eventXrayCrashError": "Ошибка: {{ .Error }}", + "eventCPUHigh": "Высокая загрузка CPU", + "eventCPUHighDetail": "CPU: {{ .Detail }}", + "eventLoginFallback": "Неудачный вход с {{ .Source }}", + "eventDelayDetail": "Задержка: {{ .Delay }} мс", + "eventErrorDetail": "Ошибка: {{ .Error }}", + "eventNodeDown": "Узел {{ .Name }} НЕДОСТУПЕН", + "eventNodeUp": "Узел {{ .Name }} В СЕТИ" }, "buttons": { "closeKeyboard": "❌ Закрыть клавиатуру", @@ -1773,5 +1846,37 @@ "chooseClient": "Выберите клиента для входящего подключения {{ .Inbound }}", "chooseInbound": "Выберите входящее подключение" } + }, + "email": { + "subjectOutboundDown": "Исходящее подключение {{ .Tag }} НЕДОСТУПНО", + "subjectOutboundUp": "Исходящее подключение {{ .Tag }} РАБОТАЕТ", + "subjectXrayCrash": "Сбой Xray", + "subjectCPUHigh": "Высокая загрузка CPU", + "subjectLoginSuccess": "Успешный вход", + "subjectLoginFailed": "Неудачный вход", + "titleOutboundDown": "Исходящее подключение НЕДОСТУПНО", + "titleOutboundUp": "Исходящее подключение РАБОТАЕТ", + "titleXrayCrash": "Сбой Xray", + "titleCPUHigh": "Высокая загрузка CPU", + "titleLoginSuccess": "Успешный вход", + "titleLoginFailed": "Неудачный вход", + "labelStatus": "Статус", + "labelOutbound": "Исходящее подключение", + "labelError": "Ошибка", + "labelDelay": "Задержка", + "labelDetail": "Подробности", + "labelUsername": "Имя пользователя", + "labelIP": "IP", + "labelReason": "Причина", + "labelSource": "Источник", + "labelTime": "Время", + "statusCrashed": "СБОЙ", + "statusRunning": "Работает", + "statusHigh": "ВЫСОКАЯ", + "statusSuccess": "УСПЕШНО", + "statusFailed": "НЕУДАЧНО", + "statusDown": "НЕДОСТУПЕН", + "statusUp": "РАБОТАЕТ", + "labelNode": "Узел" } } \ No newline at end of file diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 6d038a1f0..4472aabc7 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -1199,7 +1199,69 @@ "userPassMustBeNotEmpty": "Yeni kullanıcı adı ve şifre boş olamaz.", "getOutboundTrafficError": "Giden trafik alınırken hata oluştu.", "resetOutboundTrafficError": "Giden trafik sıfırlanırken hata oluştu." - } + }, + "emailNotifications": "Bildirimler", + "emailSettings": "E-posta", + "eventCPUHigh": "Yüksek CPU (%)", + "eventGroupOutbound": "Giden Bağlantı", + "eventGroupSecurity": "Güvenlik", + "eventGroupSystem": "Sistem", + "eventGroupXray": "Xray Çekirdeği", + "eventLoginAttempt": "Oturum açma denemesi", + "eventOutboundDown": "Çevrimdışı", + "eventOutboundUp": "Çevrimiçi", + "eventXrayCrash": "Çökme", + "requestFailed": "İstek başarısız oldu", + "smtpEnable": "E-posta Bildirimlerini Etkinleştir", + "smtpEnableDesc": "SMTP üzerinden e-posta bildirimlerini etkinleştirin", + "smtpEncryption": "Şifreleme", + "smtpEncryptionDesc": "SMTP bağlantı şifreleme yöntemi", + "smtpEncryptionNone": "Yok (düz metin)", + "smtpEncryptionStartTLS": "STARTTLS", + "smtpEncryptionTLS": "TLS (örtük)", + "smtpEventBusNotify": "E-posta Olay Bildirimleri", + "smtpEventBusNotifyDesc": "Hangi olayların e-posta bildirimi tetikleyeceğini seçin", + "smtpHost": "SMTP Sunucusu", + "smtpHostDesc": "SMTP sunucu ana bilgisayar adı (örn. smtp.gmail.com)", + "smtpHostNotConfigured": "SMTP sunucusu yapılandırılmamış", + "smtpNoRecipients": "Yapılandırılmış alıcı yok", + "smtpNotInitialized": "SMTP başlatılmadı", + "smtpPassword": "SMTP Parolası", + "smtpPasswordDesc": "SMTP kimlik doğrulama parolası", + "smtpPort": "SMTP Bağlantı Noktası", + "smtpPortDesc": "SMTP sunucu bağlantı noktası (varsayılan: 587)", + "smtpSettings": "SMTP Ayarları", + "smtpStageAuth": "Kimlik Doğrulama", + "smtpStageConnect": "Bağlantı", + "smtpStageSend": "Gönderim", + "smtpTestSuccess": "Test e-postası başarıyla gönderildi", + "smtpTo": "Alıcılar", + "smtpToDesc": "Virgülle ayrılmış alıcı e-posta adresleri", + "smtpUsername": "SMTP Kullanıcı Adı", + "smtpUsernameDesc": "SMTP kimlik doğrulama kullanıcı adı", + "telegramTokenConfigured": "Yapılandırıldı; mevcut belirteci korumak için boş bırakın.", + "telegramTokenPlaceholder": "Yapılandırıldı - değiştirmek için yeni bir belirteç girin", + "testSmtp": "Test E-postası Gönder", + "testTgBot": "Test Mesajı Gönder", + "tgBotNotEnabled": "Telegram botu etkin değil", + "tgBotNotRunning": "Telegram botu çalışmıyor", + "tgEventBusNotify": "Telegram Olay Bildirimleri", + "tgEventBusNotifyDesc": "Hangi olayların Telegram bildirimi tetikleyeceğini seçin", + "tgTestFailed": "Telegram testi başarısız oldu", + "tgTestSuccess": "Test mesajı Telegram'a gönderildi", + "smtpErrorAuth": "Kimlik doğrulama başarısız — kullanıcı adını ve parolayı kontrol edin", + "smtpErrorStarttls": "Sunucu STARTTLS gerektiriyor — şifreleme türünü değiştirin", + "smtpErrorTls": "Sunucu TLS gerektiriyor — şifreleme türünü değiştirin", + "smtpErrorRefused": "Bağlantı reddedildi — sunucuyu ve bağlantı noktasını kontrol edin", + "smtpErrorTimeout": "Bağlantı zaman aşımına uğradı — sunucuya ulaşılamıyor", + "smtpErrorRelay": "Sunucu bu adresten gönderimi reddediyor", + "smtpErrorEof": "Bağlantı sunucu tarafından kapatıldı", + "smtpErrorUnknown": "SMTP hatası: {{ .Error }}", + "eventGroupNode": "Düğümler", + "eventNodeDown": "Çevrimdışı", + "eventNodeUp": "Çevrimiçi", + "smtpPasswordConfigured": "Yapılandırıldı; mevcut parolayı korumak için boş bırakın.", + "smtpPasswordPlaceholder": "Yapılandırıldı - değiştirmek için yeni bir parola girin" }, "xray": { "title": "Xray Yapılandırmaları", @@ -1702,7 +1764,18 @@ "AreYouSure": "Emin misiniz? 🤔", "SuccessResetTraffic": "📧 E-posta: {{ .ClientEmail }}\n🏁 Sonuç: ✅ Başarılı", "FailedResetTraffic": "📧 E-posta: {{ .ClientEmail }}\n🏁 Sonuç: ❌ Başarısız \n\n🛠️ Hata: [ {{ .ErrorMessage }} ]", - "FinishProcess": "🔚 Tüm kullanıcılar için trafik sıfırlama işlemi tamamlandı." + "FinishProcess": "🔚 Tüm kullanıcılar için trafik sıfırlama işlemi tamamlandı.", + "eventCPUHigh": "Yüksek CPU", + "eventCPUHighDetail": "CPU: {{ .Detail }}", + "eventDelayDetail": "Gecikme: {{ .Delay }}ms", + "eventErrorDetail": "Hata: {{ .Error }}", + "eventLoginFallback": "{{ .Source }} adresinden oturum açma başarısız", + "eventOutboundDown": "{{ .Tag }} giden bağlantısı ÇEVRİMDIŞI", + "eventOutboundUp": "{{ .Tag }} giden bağlantısı ÇEVRİMİÇİ", + "eventXrayCrash": "Xray ÇÖKTÜ", + "eventXrayCrashError": "Hata: {{ .Error }}", + "eventNodeDown": "{{ .Name }} düğümü ÇEVRİMDIŞI", + "eventNodeUp": "{{ .Name }} düğümü ÇEVRİMİÇİ" }, "buttons": { "closeKeyboard": "❌ Klavyeyi Kapat", @@ -1772,5 +1845,57 @@ "chooseClient": "Gelen Bağlantı {{ .Inbound }} için bir Kullanıcı Seçin", "chooseInbound": "Bir Gelen Bağlantı Seçin" } + }, + "email": { + "labelDelay": "Gecikme", + "labelDetail": "Ayrıntı", + "labelError": "Hata", + "labelIP": "IP", + "labelOutbound": "Giden Bağlantı", + "labelReason": "Neden", + "labelSource": "Kaynak", + "labelStatus": "Durum", + "labelTime": "Zaman", + "labelUsername": "Kullanıcı Adı", + "statusBanned": "BANNED", + "statusCrashed": "ÇÖKTÜ", + "statusDown": "ÇEVRİMDIŞI", + "statusFailed": "BAŞARISIZ", + "statusFull": "FULL", + "statusHigh": "YÜKSEK", + "statusOffline": "OFFLINE", + "statusOnline": "ONLINE", + "statusRunning": "Çalışıyor", + "statusSuccess": "BAŞARILI", + "statusUp": "ÇEVRİMİÇİ", + "statusXrayDown": "Xray DOWN", + "statusXrayUp": "Xray UP", + "subjectCPUHigh": "Yüksek CPU", + "subjectDiskFull": "Disk full", + "subjectIPBanned": "IP banned: {{ .IP }}", + "subjectLoginFailed": "Oturum açma başarısız", + "subjectLoginSuccess": "Oturum açma başarılı", + "subjectNodeOffline": "Node {{ .Node }} is OFFLINE", + "subjectNodeOnline": "Node {{ .Node }} is ONLINE", + "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN", + "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP", + "subjectOutboundDown": "{{ .Tag }} giden bağlantısı ÇEVRİMDIŞI", + "subjectOutboundUp": "{{ .Tag }} giden bağlantısı ÇEVRİMİÇİ", + "subjectXrayCrash": "Xray ÇÖKTÜ", + "subjectXrayUp": "Xray is UP", + "titleCPUHigh": "Yüksek CPU", + "titleDiskFull": "Disk full", + "titleIPBanned": "IP banned", + "titleLoginFailed": "Oturum açma başarısız", + "titleLoginSuccess": "Oturum açma başarılı", + "titleNodeOffline": "Node OFFLINE", + "titleNodeOnline": "Node ONLINE", + "titleNodeXrayDown": "Node Xray DOWN", + "titleNodeXrayUp": "Node Xray UP", + "titleOutboundDown": "Giden Bağlantı ÇEVRİMDIŞI", + "titleOutboundUp": "Giden Bağlantı ÇEVRİMİÇİ", + "titleXrayCrash": "Xray ÇÖKTÜ", + "titleXrayUp": "Xray UP", + "labelNode": "Düğüm" } } \ No newline at end of file diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 2b97aad65..d3c0cdb57 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -1200,7 +1200,69 @@ "userPassMustBeNotEmpty": "Нове ім'я користувача та пароль порожні", "getOutboundTrafficError": "Помилка отримання вихідного трафіку", "resetOutboundTrafficError": "Помилка скидання вихідного трафіку" - } + }, + "emailNotifications": "Сповіщення", + "emailSettings": "Електронна пошта", + "eventCPUHigh": "Високе навантаження на CPU (%)", + "eventGroupOutbound": "Вихідні з'єднання", + "eventGroupSecurity": "Безпека", + "eventGroupSystem": "Система", + "eventGroupXray": "Ядро Xray", + "eventLoginAttempt": "Спроба входу", + "eventOutboundDown": "Недоступне", + "eventOutboundUp": "Доступне", + "eventXrayCrash": "Збій", + "requestFailed": "Запит не вдалося виконати", + "smtpEnable": "Увімкнути сповіщення електронною поштою", + "smtpEnableDesc": "Увімкнути сповіщення електронною поштою через SMTP", + "smtpEncryption": "Шифрування", + "smtpEncryptionDesc": "Метод шифрування з'єднання SMTP", + "smtpEncryptionNone": "Немає (відкритий текст)", + "smtpEncryptionStartTLS": "STARTTLS", + "smtpEncryptionTLS": "TLS (неявне)", + "smtpEventBusNotify": "Сповіщення про події електронною поштою", + "smtpEventBusNotifyDesc": "Виберіть, які події спричиняють сповіщення електронною поштою", + "smtpHost": "Хост SMTP", + "smtpHostDesc": "Ім'я хоста сервера SMTP (наприклад, smtp.gmail.com)", + "smtpHostNotConfigured": "Хост SMTP не налаштовано", + "smtpNoRecipients": "Отримувачів не налаштовано", + "smtpNotInitialized": "SMTP не ініціалізовано", + "smtpPassword": "Пароль SMTP", + "smtpPasswordDesc": "Пароль для автентифікації SMTP", + "smtpPort": "Порт SMTP", + "smtpPortDesc": "Порт сервера SMTP (типово: 587)", + "smtpSettings": "Налаштування SMTP", + "smtpStageAuth": "Автентифікація", + "smtpStageConnect": "З'єднання", + "smtpStageSend": "Надсилання", + "smtpTestSuccess": "Тестовий лист успішно надіслано", + "smtpTo": "Отримувачі", + "smtpToDesc": "Адреси електронної пошти отримувачів, розділені комами", + "smtpUsername": "Ім'я користувача SMTP", + "smtpUsernameDesc": "Ім'я користувача для автентифікації SMTP", + "telegramTokenConfigured": "Налаштовано; залиште порожнім, щоб зберегти поточний токен.", + "telegramTokenPlaceholder": "Налаштовано — введіть новий токен для заміни", + "testSmtp": "Надіслати тестовий лист", + "testTgBot": "Надіслати тестове повідомлення", + "tgBotNotEnabled": "Бот Telegram не увімкнено", + "tgBotNotRunning": "Бот Telegram не запущено", + "tgEventBusNotify": "Сповіщення про події в Telegram", + "tgEventBusNotifyDesc": "Виберіть, які події спричиняють сповіщення в Telegram", + "tgTestFailed": "Тест Telegram не вдався", + "tgTestSuccess": "Тестове повідомлення надіслано в Telegram", + "smtpErrorAuth": "Помилка автентифікації — перевірте ім'я користувача та пароль", + "smtpErrorStarttls": "Сервер вимагає STARTTLS — змініть тип шифрування", + "smtpErrorTls": "Сервер вимагає TLS — змініть тип шифрування", + "smtpErrorRefused": "У з'єднанні відмовлено — перевірте хост і порт", + "smtpErrorTimeout": "Час очікування з'єднання вичерпано — хост недоступний", + "smtpErrorRelay": "Сервер відхиляє надсилання з цієї адреси", + "smtpErrorEof": "З'єднання закрито сервером", + "smtpErrorUnknown": "Помилка SMTP: {{ .Error }}", + "eventGroupNode": "Вузли", + "eventNodeDown": "Недоступний", + "eventNodeUp": "Доступний", + "smtpPasswordConfigured": "Налаштовано; залиште порожнім, щоб зберегти поточний пароль.", + "smtpPasswordPlaceholder": "Налаштовано — введіть новий пароль для заміни" }, "xray": { "title": "Xray конфігурації", @@ -1703,7 +1765,18 @@ "AreYouSure": "Ви впевнені? 🤔", "SuccessResetTraffic": "📧 Електронна пошта: {{ .ClientEmail }}\n🏁 Результат: ✅ Успішно", "FailedResetTraffic": "📧 Електронна пошта: {{ .ClientEmail }}\n🏁 Результат: ❌ Невдача \n\n🛠️ Помилка: [ {{ .ErrorMessage }} ]", - "FinishProcess": "🔚 Процес скидання трафіку завершено для всіх клієнтів." + "FinishProcess": "🔚 Процес скидання трафіку завершено для всіх клієнтів.", + "eventCPUHigh": "Високе навантаження на CPU", + "eventCPUHighDetail": "CPU: {{ .Detail }}", + "eventDelayDetail": "Затримка: {{ .Delay }} мс", + "eventErrorDetail": "Помилка: {{ .Error }}", + "eventLoginFallback": "Невдала спроба входу з {{ .Source }}", + "eventOutboundDown": "Вихідне з'єднання {{ .Tag }} НЕДОСТУПНЕ", + "eventOutboundUp": "Вихідне з'єднання {{ .Tag }} ДОСТУПНЕ", + "eventXrayCrash": "Стався збій Xray", + "eventXrayCrashError": "Помилка: {{ .Error }}", + "eventNodeDown": "Вузол {{ .Name }} НЕДОСТУПНИЙ", + "eventNodeUp": "Вузол {{ .Name }} ДОСТУПНИЙ" }, "buttons": { "closeKeyboard": "❌ Закрити клавіатуру", @@ -1773,5 +1846,57 @@ "chooseClient": "Виберіть клієнта для Вхідного {{ .Inbound }}", "chooseInbound": "Виберіть Вхідний" } + }, + "email": { + "labelDelay": "Затримка", + "labelDetail": "Деталі", + "labelError": "Помилка", + "labelIP": "IP", + "labelOutbound": "Вихідне з'єднання", + "labelReason": "Причина", + "labelSource": "Джерело", + "labelStatus": "Статус", + "labelTime": "Час", + "labelUsername": "Ім'я користувача", + "statusBanned": "BANNED", + "statusCrashed": "ЗБІЙ", + "statusDown": "НЕДОСТУПНО", + "statusFailed": "НЕВДАЛО", + "statusFull": "FULL", + "statusHigh": "ВИСОКЕ", + "statusOffline": "OFFLINE", + "statusOnline": "ONLINE", + "statusRunning": "Працює", + "statusSuccess": "УСПІШНО", + "statusUp": "ДОСТУПНО", + "statusXrayDown": "Xray DOWN", + "statusXrayUp": "Xray UP", + "subjectCPUHigh": "Високе навантаження на CPU", + "subjectDiskFull": "Disk full", + "subjectIPBanned": "IP banned: {{ .IP }}", + "subjectLoginFailed": "Невдалий вхід", + "subjectLoginSuccess": "Успішний вхід", + "subjectNodeOffline": "Node {{ .Node }} is OFFLINE", + "subjectNodeOnline": "Node {{ .Node }} is ONLINE", + "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN", + "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP", + "subjectOutboundDown": "Вихідне з'єднання {{ .Tag }} НЕДОСТУПНЕ", + "subjectOutboundUp": "Вихідне з'єднання {{ .Tag }} ДОСТУПНЕ", + "subjectXrayCrash": "Стався збій Xray", + "subjectXrayUp": "Xray is UP", + "titleCPUHigh": "Високе навантаження на CPU", + "titleDiskFull": "Disk full", + "titleIPBanned": "IP banned", + "titleLoginFailed": "Невдалий вхід", + "titleLoginSuccess": "Успішний вхід", + "titleNodeOffline": "Node OFFLINE", + "titleNodeOnline": "Node ONLINE", + "titleNodeXrayDown": "Node Xray DOWN", + "titleNodeXrayUp": "Node Xray UP", + "titleOutboundDown": "Вихідне з'єднання НЕДОСТУПНЕ", + "titleOutboundUp": "Вихідне з'єднання ДОСТУПНЕ", + "titleXrayCrash": "Стався збій Xray", + "titleXrayUp": "Xray UP", + "labelNode": "Вузол" } } \ No newline at end of file diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 5ee5bbd6f..cecc2003b 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -1200,7 +1200,69 @@ "userPassMustBeNotEmpty": "Tên người dùng mới và mật khẩu mới không thể để trống", "getOutboundTrafficError": "Lỗi khi lấy lưu lượng truy cập đi", "resetOutboundTrafficError": "Lỗi khi đặt lại lưu lượng truy cập đi" - } + }, + "emailNotifications": "Thông báo", + "emailSettings": "Email", + "eventCPUHigh": "CPU cao (%)", + "eventGroupOutbound": "Outbound", + "eventGroupSecurity": "Bảo mật", + "eventGroupSystem": "Hệ thống", + "eventGroupXray": "Xray Core", + "eventLoginAttempt": "Lần thử đăng nhập", + "eventOutboundDown": "Ngừng hoạt động", + "eventOutboundUp": "Hoạt động", + "eventXrayCrash": "Sự cố", + "requestFailed": "Yêu cầu thất bại", + "smtpEnable": "Bật thông báo qua email", + "smtpEnableDesc": "Bật thông báo qua email bằng SMTP", + "smtpEncryption": "Mã hóa", + "smtpEncryptionDesc": "Phương thức mã hóa kết nối SMTP", + "smtpEncryptionNone": "Không (văn bản thuần)", + "smtpEncryptionStartTLS": "STARTTLS", + "smtpEncryptionTLS": "TLS (ngầm định)", + "smtpEventBusNotify": "Thông báo sự kiện qua email", + "smtpEventBusNotifyDesc": "Chọn những sự kiện nào sẽ kích hoạt thông báo qua email", + "smtpHost": "Máy chủ SMTP", + "smtpHostDesc": "Tên máy chủ SMTP (ví dụ: smtp.gmail.com)", + "smtpHostNotConfigured": "Chưa cấu hình máy chủ SMTP", + "smtpNoRecipients": "Chưa cấu hình người nhận", + "smtpNotInitialized": "SMTP chưa được khởi tạo", + "smtpPassword": "Mật khẩu SMTP", + "smtpPasswordDesc": "Mật khẩu xác thực SMTP", + "smtpPort": "Cổng SMTP", + "smtpPortDesc": "Cổng máy chủ SMTP (mặc định: 587)", + "smtpSettings": "Cài đặt SMTP", + "smtpStageAuth": "Xác thực", + "smtpStageConnect": "Kết nối", + "smtpStageSend": "Gửi", + "smtpTestSuccess": "Đã gửi email thử nghiệm thành công", + "smtpTo": "Người nhận", + "smtpToDesc": "Các địa chỉ email người nhận, phân cách bằng dấu phẩy", + "smtpUsername": "Tên đăng nhập SMTP", + "smtpUsernameDesc": "Tên đăng nhập xác thực SMTP", + "telegramTokenConfigured": "Đã cấu hình; để trống để giữ token hiện tại.", + "telegramTokenPlaceholder": "Đã cấu hình - nhập token mới để thay thế", + "testSmtp": "Gửi email thử nghiệm", + "testTgBot": "Gửi tin nhắn thử nghiệm", + "tgBotNotEnabled": "Bot Telegram chưa được bật", + "tgBotNotRunning": "Bot Telegram không hoạt động", + "tgEventBusNotify": "Thông báo sự kiện qua Telegram", + "tgEventBusNotifyDesc": "Chọn những sự kiện nào sẽ kích hoạt thông báo qua Telegram", + "tgTestFailed": "Thử nghiệm Telegram thất bại", + "tgTestSuccess": "Đã gửi tin nhắn thử nghiệm tới Telegram", + "smtpErrorAuth": "Xác thực thất bại — kiểm tra tên đăng nhập và mật khẩu", + "smtpErrorStarttls": "Máy chủ yêu cầu STARTTLS — thay đổi kiểu mã hóa", + "smtpErrorTls": "Máy chủ yêu cầu TLS — thay đổi kiểu mã hóa", + "smtpErrorRefused": "Kết nối bị từ chối — kiểm tra máy chủ và cổng", + "smtpErrorTimeout": "Hết thời gian kết nối — không thể truy cập máy chủ", + "smtpErrorRelay": "Máy chủ từ chối gửi từ địa chỉ này", + "smtpErrorEof": "Kết nối đã bị máy chủ đóng", + "smtpErrorUnknown": "Lỗi SMTP: {{ .Error }}", + "eventGroupNode": "Node", + "eventNodeDown": "Ngừng hoạt động", + "eventNodeUp": "Hoạt động", + "smtpPasswordConfigured": "Đã cấu hình; để trống để giữ mật khẩu hiện tại.", + "smtpPasswordPlaceholder": "Đã cấu hình - nhập mật khẩu mới để thay thế" }, "xray": { "title": "Cài đặt Xray", @@ -1703,7 +1765,18 @@ "AreYouSure": "Bạn có chắc không? 🤔", "SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Kết quả: ✅ Thành công", "FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Kết quả: ❌ Thất bại \n\n🛠️ Lỗi: [ {{ .ErrorMessage }} ]", - "FinishProcess": "🔚 Quá trình đặt lại lưu lượng đã hoàn tất cho tất cả khách hàng." + "FinishProcess": "🔚 Quá trình đặt lại lưu lượng đã hoàn tất cho tất cả khách hàng.", + "eventCPUHigh": "CPU cao", + "eventCPUHighDetail": "CPU: {{ .Detail }}", + "eventDelayDetail": "Độ trễ: {{ .Delay }}ms", + "eventErrorDetail": "Lỗi: {{ .Error }}", + "eventLoginFallback": "Đăng nhập thất bại từ {{ .Source }}", + "eventOutboundDown": "Outbound {{ .Tag }} đã NGỪNG HOẠT ĐỘNG", + "eventOutboundUp": "Outbound {{ .Tag }} đã HOẠT ĐỘNG", + "eventXrayCrash": "Xray GẶP SỰ CỐ", + "eventXrayCrashError": "Lỗi: {{ .Error }}", + "eventNodeDown": "Node {{ .Name }} đã NGỪNG HOẠT ĐỘNG", + "eventNodeUp": "Node {{ .Name }} đã HOẠT ĐỘNG" }, "buttons": { "closeKeyboard": "❌ Đóng Bàn Phím", @@ -1773,5 +1846,57 @@ "chooseClient": "Chọn một Khách hàng cho Inbound {{ .Inbound }}", "chooseInbound": "Chọn một Inbound" } + }, + "email": { + "labelDelay": "Độ trễ", + "labelDetail": "Chi tiết", + "labelError": "Lỗi", + "labelIP": "IP", + "labelOutbound": "Outbound", + "labelReason": "Lý do", + "labelSource": "Nguồn", + "labelStatus": "Trạng thái", + "labelTime": "Thời gian", + "labelUsername": "Tên đăng nhập", + "statusBanned": "BANNED", + "statusCrashed": "GẶP SỰ CỐ", + "statusDown": "NGỪNG HOẠT ĐỘNG", + "statusFailed": "THẤT BẠI", + "statusFull": "FULL", + "statusHigh": "CAO", + "statusOffline": "OFFLINE", + "statusOnline": "ONLINE", + "statusRunning": "Đang chạy", + "statusSuccess": "THÀNH CÔNG", + "statusUp": "HOẠT ĐỘNG", + "statusXrayDown": "Xray DOWN", + "statusXrayUp": "Xray UP", + "subjectCPUHigh": "CPU cao", + "subjectDiskFull": "Disk full", + "subjectIPBanned": "IP banned: {{ .IP }}", + "subjectLoginFailed": "Đăng nhập thất bại", + "subjectLoginSuccess": "Đăng nhập thành công", + "subjectNodeOffline": "Node {{ .Node }} is OFFLINE", + "subjectNodeOnline": "Node {{ .Node }} is ONLINE", + "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN", + "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP", + "subjectOutboundDown": "Outbound {{ .Tag }} đã NGỪNG HOẠT ĐỘNG", + "subjectOutboundUp": "Outbound {{ .Tag }} đã HOẠT ĐỘNG", + "subjectXrayCrash": "Xray GẶP SỰ CỐ", + "subjectXrayUp": "Xray is UP", + "titleCPUHigh": "CPU cao", + "titleDiskFull": "Disk full", + "titleIPBanned": "IP banned", + "titleLoginFailed": "Đăng nhập thất bại", + "titleLoginSuccess": "Đăng nhập thành công", + "titleNodeOffline": "Node OFFLINE", + "titleNodeOnline": "Node ONLINE", + "titleNodeXrayDown": "Node Xray DOWN", + "titleNodeXrayUp": "Node Xray UP", + "titleOutboundDown": "Outbound NGỪNG HOẠT ĐỘNG", + "titleOutboundUp": "Outbound HOẠT ĐỘNG", + "titleXrayCrash": "Xray GẶP SỰ CỐ", + "titleXrayUp": "Xray UP", + "labelNode": "Node" } } \ No newline at end of file diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 9a503359a..d582d03e9 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -1200,7 +1200,69 @@ "userPassMustBeNotEmpty": "新用户名和新密码不能为空", "getOutboundTrafficError": "获取出站流量错误", "resetOutboundTrafficError": "重置出站流量错误" - } + }, + "emailNotifications": "通知", + "emailSettings": "邮件", + "eventCPUHigh": "CPU 占用过高(%)", + "eventGroupOutbound": "出站", + "eventGroupSecurity": "安全", + "eventGroupSystem": "系统", + "eventGroupXray": "Xray 核心", + "eventLoginAttempt": "登录尝试", + "eventOutboundDown": "断开", + "eventOutboundUp": "恢复", + "eventXrayCrash": "崩溃", + "requestFailed": "请求失败", + "smtpEnable": "启用邮件通知", + "smtpEnableDesc": "通过 SMTP 启用邮件通知", + "smtpEncryption": "加密", + "smtpEncryptionDesc": "SMTP 连接加密方式", + "smtpEncryptionNone": "无(明文)", + "smtpEncryptionStartTLS": "STARTTLS", + "smtpEncryptionTLS": "TLS(隐式)", + "smtpEventBusNotify": "邮件事件通知", + "smtpEventBusNotifyDesc": "选择触发邮件通知的事件", + "smtpHost": "SMTP 主机", + "smtpHostDesc": "SMTP 服务器主机名(例如 smtp.gmail.com)", + "smtpHostNotConfigured": "尚未配置 SMTP 主机", + "smtpNoRecipients": "尚未配置收件人", + "smtpNotInitialized": "SMTP 尚未初始化", + "smtpPassword": "SMTP 密码", + "smtpPasswordDesc": "SMTP 认证密码", + "smtpPort": "SMTP 端口", + "smtpPortDesc": "SMTP 服务器端口(默认:587)", + "smtpSettings": "SMTP 设置", + "smtpStageAuth": "认证", + "smtpStageConnect": "连接", + "smtpStageSend": "发送", + "smtpTestSuccess": "测试邮件发送成功", + "smtpTo": "收件人", + "smtpToDesc": "以逗号分隔的收件人邮箱地址", + "smtpUsername": "SMTP 用户名", + "smtpUsernameDesc": "SMTP 认证用户名", + "telegramTokenConfigured": "已配置;留空则保留当前令牌。", + "telegramTokenPlaceholder": "已配置——输入新令牌以替换", + "testSmtp": "发送测试邮件", + "testTgBot": "发送测试消息", + "tgBotNotEnabled": "Telegram 机器人未启用", + "tgBotNotRunning": "Telegram 机器人未运行", + "tgEventBusNotify": "Telegram 事件通知", + "tgEventBusNotifyDesc": "选择触发 Telegram 通知的事件", + "tgTestFailed": "Telegram 测试失败", + "tgTestSuccess": "测试消息已发送至 Telegram", + "smtpErrorAuth": "认证失败——请检查用户名和密码", + "smtpErrorStarttls": "服务器要求 STARTTLS——请更改加密类型", + "smtpErrorTls": "服务器要求 TLS——请更改加密类型", + "smtpErrorRefused": "连接被拒绝——请检查主机和端口", + "smtpErrorTimeout": "连接超时——主机无法访问", + "smtpErrorRelay": "服务器拒绝从此地址发送", + "smtpErrorEof": "连接被服务器关闭", + "smtpErrorUnknown": "SMTP 错误:{{ .Error }}", + "eventGroupNode": "节点", + "eventNodeDown": "离线", + "eventNodeUp": "上线", + "smtpPasswordConfigured": "已配置;留空则保留当前密码。", + "smtpPasswordPlaceholder": "已配置——输入新密码以替换" }, "xray": { "title": "Xray 配置", @@ -1703,7 +1765,18 @@ "AreYouSure": "你确定吗?🤔", "SuccessResetTraffic": "📧 邮箱: {{ .ClientEmail }}\n🏁 结果: ✅ 成功", "FailedResetTraffic": "📧 邮箱: {{ .ClientEmail }}\n🏁 结果: ❌ 失败 \n\n🛠️ 错误: [ {{ .ErrorMessage }} ]", - "FinishProcess": "🔚 所有客户的流量重置已完成。" + "FinishProcess": "🔚 所有客户的流量重置已完成。", + "eventCPUHigh": "CPU 占用过高", + "eventCPUHighDetail": "CPU:{{ .Detail }}", + "eventDelayDetail": "延迟:{{ .Delay }} 毫秒", + "eventErrorDetail": "错误:{{ .Error }}", + "eventLoginFallback": "来自 {{ .Source }} 的登录失败", + "eventOutboundDown": "出站 {{ .Tag }} 已断开", + "eventOutboundUp": "出站 {{ .Tag }} 已恢复", + "eventXrayCrash": "Xray 已崩溃", + "eventXrayCrashError": "错误:{{ .Error }}", + "eventNodeDown": "节点 {{ .Name }} 已离线", + "eventNodeUp": "节点 {{ .Name }} 已上线" }, "buttons": { "closeKeyboard": "❌ 关闭键盘", @@ -1773,5 +1846,57 @@ "chooseClient": "为入站 {{ .Inbound }} 选择一个客户", "chooseInbound": "选择一个入站" } + }, + "email": { + "labelDelay": "延迟", + "labelDetail": "详情", + "labelError": "错误", + "labelIP": "IP", + "labelOutbound": "出站", + "labelReason": "原因", + "labelSource": "来源", + "labelStatus": "状态", + "labelTime": "时间", + "labelUsername": "用户名", + "statusBanned": "BANNED", + "statusCrashed": "已崩溃", + "statusDown": "断开", + "statusFailed": "失败", + "statusFull": "FULL", + "statusHigh": "过高", + "statusOffline": "OFFLINE", + "statusOnline": "ONLINE", + "statusRunning": "运行中", + "statusSuccess": "成功", + "statusUp": "恢复", + "statusXrayDown": "Xray DOWN", + "statusXrayUp": "Xray UP", + "subjectCPUHigh": "CPU 占用过高", + "subjectDiskFull": "Disk full", + "subjectIPBanned": "IP banned: {{ .IP }}", + "subjectLoginFailed": "登录失败", + "subjectLoginSuccess": "登录成功", + "subjectNodeOffline": "Node {{ .Node }} is OFFLINE", + "subjectNodeOnline": "Node {{ .Node }} is ONLINE", + "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN", + "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP", + "subjectOutboundDown": "出站 {{ .Tag }} 已断开", + "subjectOutboundUp": "出站 {{ .Tag }} 已恢复", + "subjectXrayCrash": "Xray 已崩溃", + "subjectXrayUp": "Xray is UP", + "titleCPUHigh": "CPU 占用过高", + "titleDiskFull": "Disk full", + "titleIPBanned": "IP banned", + "titleLoginFailed": "登录失败", + "titleLoginSuccess": "登录成功", + "titleNodeOffline": "Node OFFLINE", + "titleNodeOnline": "Node ONLINE", + "titleNodeXrayDown": "Node Xray DOWN", + "titleNodeXrayUp": "Node Xray UP", + "titleOutboundDown": "出站断开", + "titleOutboundUp": "出站恢复", + "titleXrayCrash": "Xray 已崩溃", + "titleXrayUp": "Xray UP", + "labelNode": "节点" } } \ No newline at end of file diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 6314fd55b..c9ac372f2 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -1200,7 +1200,69 @@ "userPassMustBeNotEmpty": "新使用者名稱和新密碼不能為空", "getOutboundTrafficError": "取得出站流量錯誤", "resetOutboundTrafficError": "重設出站流量錯誤" - } + }, + "emailNotifications": "通知", + "emailSettings": "電子郵件", + "eventCPUHigh": "CPU 偏高(%)", + "eventGroupOutbound": "出站", + "eventGroupSecurity": "安全性", + "eventGroupSystem": "系統", + "eventGroupXray": "Xray 核心", + "eventLoginAttempt": "登入嘗試", + "eventOutboundDown": "中斷", + "eventOutboundUp": "恢復", + "eventXrayCrash": "當機", + "requestFailed": "請求失敗", + "smtpEnable": "啟用電子郵件通知", + "smtpEnableDesc": "透過 SMTP 啟用電子郵件通知", + "smtpEncryption": "加密", + "smtpEncryptionDesc": "SMTP 連線加密方式", + "smtpEncryptionNone": "無(純文字)", + "smtpEncryptionStartTLS": "STARTTLS", + "smtpEncryptionTLS": "TLS(隱含)", + "smtpEventBusNotify": "電子郵件事件通知", + "smtpEventBusNotifyDesc": "選擇觸發電子郵件通知的事件", + "smtpHost": "SMTP 主機", + "smtpHostDesc": "SMTP 伺服器主機名稱(例如 smtp.gmail.com)", + "smtpHostNotConfigured": "尚未設定 SMTP 主機", + "smtpNoRecipients": "尚未設定收件人", + "smtpNotInitialized": "SMTP 尚未初始化", + "smtpPassword": "SMTP 密碼", + "smtpPasswordDesc": "SMTP 驗證密碼", + "smtpPort": "SMTP 連接埠", + "smtpPortDesc": "SMTP 伺服器連接埠(預設:587)", + "smtpSettings": "SMTP 設定", + "smtpStageAuth": "驗證", + "smtpStageConnect": "連線", + "smtpStageSend": "傳送", + "smtpTestSuccess": "測試郵件已成功傳送", + "smtpTo": "收件人", + "smtpToDesc": "以逗號分隔的收件人電子郵件地址", + "smtpUsername": "SMTP 使用者名稱", + "smtpUsernameDesc": "SMTP 驗證使用者名稱", + "telegramTokenConfigured": "已設定;留空以保留目前的權杖。", + "telegramTokenPlaceholder": "已設定 - 輸入新權杖以取代", + "testSmtp": "傳送測試郵件", + "testTgBot": "傳送測試訊息", + "tgBotNotEnabled": "Telegram 機器人未啟用", + "tgBotNotRunning": "Telegram 機器人未執行", + "tgEventBusNotify": "Telegram 事件通知", + "tgEventBusNotifyDesc": "選擇觸發 Telegram 通知的事件", + "tgTestFailed": "Telegram 測試失敗", + "tgTestSuccess": "測試訊息已傳送至 Telegram", + "smtpErrorAuth": "驗證失敗 — 請檢查使用者名稱和密碼", + "smtpErrorStarttls": "伺服器需要 STARTTLS — 請變更加密類型", + "smtpErrorTls": "伺服器需要 TLS — 請變更加密類型", + "smtpErrorRefused": "連線遭拒 — 請檢查主機和連接埠", + "smtpErrorTimeout": "連線逾時 — 無法連線至主機", + "smtpErrorRelay": "伺服器拒絕從此地址傳送", + "smtpErrorEof": "連線已被伺服器關閉", + "smtpErrorUnknown": "SMTP 錯誤:{{ .Error }}", + "eventGroupNode": "節點", + "eventNodeDown": "離線", + "eventNodeUp": "上線", + "smtpPasswordConfigured": "已設定;留空以保留目前的密碼。", + "smtpPasswordPlaceholder": "已設定 - 輸入新密碼以取代" }, "xray": { "title": "Xray 配置", @@ -1703,7 +1765,18 @@ "AreYouSure": "你確定嗎?🤔", "SuccessResetTraffic": "📧 電子郵件: {{ .ClientEmail }}\n🏁 結果: ✅ 成功", "FailedResetTraffic": "📧 電子郵件: {{ .ClientEmail }}\n🏁 結果: ❌ 失敗 \n\n🛠️ 錯誤: [ {{ .ErrorMessage }} ]", - "FinishProcess": "🔚 所有客戶的流量重置已完成。" + "FinishProcess": "🔚 所有客戶的流量重置已完成。", + "eventCPUHigh": "CPU 偏高", + "eventCPUHighDetail": "CPU:{{ .Detail }}", + "eventDelayDetail": "延遲:{{ .Delay }} 毫秒", + "eventErrorDetail": "錯誤:{{ .Error }}", + "eventLoginFallback": "來自 {{ .Source }} 的登入失敗", + "eventOutboundDown": "出站 {{ .Tag }} 已中斷", + "eventOutboundUp": "出站 {{ .Tag }} 已恢復", + "eventXrayCrash": "Xray 已當機", + "eventXrayCrashError": "錯誤:{{ .Error }}", + "eventNodeDown": "節點 {{ .Name }} 已離線", + "eventNodeUp": "節點 {{ .Name }} 已上線" }, "buttons": { "closeKeyboard": "❌ 關閉鍵盤", @@ -1773,5 +1846,57 @@ "chooseClient": "為入站 {{ .Inbound }} 選擇一個客戶", "chooseInbound": "選擇一個入站" } + }, + "email": { + "labelDelay": "延遲", + "labelDetail": "詳細資訊", + "labelError": "錯誤", + "labelIP": "IP", + "labelOutbound": "出站", + "labelReason": "原因", + "labelSource": "來源", + "labelStatus": "狀態", + "labelTime": "時間", + "labelUsername": "使用者名稱", + "statusBanned": "BANNED", + "statusCrashed": "已當機", + "statusDown": "中斷", + "statusFailed": "失敗", + "statusFull": "FULL", + "statusHigh": "偏高", + "statusOffline": "OFFLINE", + "statusOnline": "ONLINE", + "statusRunning": "執行中", + "statusSuccess": "成功", + "statusUp": "恢復", + "statusXrayDown": "Xray DOWN", + "statusXrayUp": "Xray UP", + "subjectCPUHigh": "CPU 偏高", + "subjectDiskFull": "Disk full", + "subjectIPBanned": "IP banned: {{ .IP }}", + "subjectLoginFailed": "登入失敗", + "subjectLoginSuccess": "登入成功", + "subjectNodeOffline": "Node {{ .Node }} is OFFLINE", + "subjectNodeOnline": "Node {{ .Node }} is ONLINE", + "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN", + "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP", + "subjectOutboundDown": "出站 {{ .Tag }} 已中斷", + "subjectOutboundUp": "出站 {{ .Tag }} 已恢復", + "subjectXrayCrash": "Xray 已當機", + "subjectXrayUp": "Xray is UP", + "titleCPUHigh": "CPU 偏高", + "titleDiskFull": "Disk full", + "titleIPBanned": "IP banned", + "titleLoginFailed": "登入失敗", + "titleLoginSuccess": "登入成功", + "titleNodeOffline": "Node OFFLINE", + "titleNodeOnline": "Node ONLINE", + "titleNodeXrayDown": "Node Xray DOWN", + "titleNodeXrayUp": "Node Xray UP", + "titleOutboundDown": "出站中斷", + "titleOutboundUp": "出站恢復", + "titleXrayCrash": "Xray 已當機", + "titleXrayUp": "Xray UP", + "labelNode": "節點" } } \ No newline at end of file diff --git a/internal/web/web.go b/internal/web/web.go index 22a800506..7822b734a 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -6,6 +6,7 @@ import ( "context" "crypto/tls" "embed" + "fmt" "io" "io/fs" "net" @@ -16,6 +17,7 @@ import ( "time" "github.com/mhsanaei/3x-ui/v3/internal/config" + "github.com/mhsanaei/3x-ui/v3/internal/eventbus" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/mtproto" "github.com/mhsanaei/3x-ui/v3/internal/util/common" @@ -26,9 +28,11 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/web/network" "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/web/service/email" "github.com/mhsanaei/3x-ui/v3/internal/web/service/panel" "github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot" "github.com/mhsanaei/3x-ui/v3/internal/web/websocket" + "github.com/mhsanaei/3x-ui/v3/internal/xray" "github.com/gin-contrib/gzip" "github.com/gin-contrib/sessions" @@ -112,6 +116,7 @@ type Server struct { wsHub *websocket.Hub + bus *eventbus.Bus cron *cron.Cron ctx context.Context @@ -277,7 +282,9 @@ const ( cadenceNodeTraffic = "@every 5s" cadenceOutboundSub = "@every 5m" cadenceCheckHash = "@every 2m" - cadenceCPUAlarm = "@every 10s" + // cpu.Percent samples over a full minute (blocking), so a finer cadence just + // stacks overlapping samplers; subscribers rate-limit alerts to 1/min anyway. + cadenceCPUAlarm = "@every 1m" ) // startTask schedules background jobs (Xray checks, traffic jobs, cron @@ -347,8 +354,7 @@ func (s *Server) startTask(restartXray bool) { s.cron.AddJob(runtime, j) } - // Make a traffic condition every day, 8:30 - var entry cron.EntryID + // Telegram-bot–dependent jobs: periodic stats report + callback-hash cleanup. isTgbotenabled, err := s.settingService.GetTgbotEnabled() if (err == nil) && (isTgbotenabled) { runtime, err := s.settingService.GetTgbotRuntime() @@ -360,23 +366,50 @@ func (s *Server) startTask(restartXray bool) { runtime = "@daily" } logger.Infof("Tg notify enabled,run at %s", runtime) - _, err = s.cron.AddJob(runtime, job.NewStatsNotifyJob()) - if err != nil { + if _, err = s.cron.AddJob(runtime, job.NewStatsNotifyJob()); err != nil { logger.Warningf("Add NewStatsNotifyJob: failed to schedule runtime %q: %v", runtime, err) - return } // check for Telegram bot callback query hash storage reset s.cron.AddJob(cadenceCheckHash, job.NewCheckHashStorageJob()) - - // Check CPU load and alarm to TgBot if threshold passes - cpuThreshold, err := s.settingService.GetTgCpu() - if (err == nil) && (cpuThreshold > 0) { - s.cron.AddJob(cadenceCPUAlarm, job.NewCheckCpuJob()) - } - } else { - s.cron.Remove(entry) } + + // CPU monitor publishes cpu.high events; register it whenever any notifier + // (Telegram or Email) wants them, independent of the Telegram bot being on. + if s.cpuAlarmWanted() { + s.cron.AddJob(cadenceCPUAlarm, job.NewCheckCpuJob()) + } +} + +// cpuAlarmWanted reports whether any notifier is configured to receive cpu.high +// alerts, so the minute-long blocking CPU sampler only runs when it's needed. +func (s *Server) cpuAlarmWanted() bool { + wants := func(events string, threshold int) bool { + if threshold <= 0 { + return false + } + for _, e := range strings.Split(events, ",") { + if strings.TrimSpace(e) == string(eventbus.EventCPUHigh) { + return true + } + } + return false + } + if on, _ := s.settingService.GetTgbotEnabled(); on { + events, _ := s.settingService.GetTgEnabledEvents() + cpu, _ := s.settingService.GetTgCpu() + if wants(events, cpu) { + return true + } + } + if on, _ := s.settingService.GetSmtpEnable(); on { + events, _ := s.settingService.GetSmtpEnabledEvents() + cpu, _ := s.settingService.GetSmtpCpu() + if wants(events, cpu) { + return true + } + } + return false } // Start initializes and starts the web server with configured settings, routes, and background jobs. @@ -479,6 +512,42 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) { s.httpServer.Serve(listener) }() + // Create event bus before startTask so jobs can use it + s.bus = eventbus.New(eventbus.DefaultBufferSize) + service.SetEventBus(s.bus) + job.EventBus = s.bus + tgbot.EventBus = s.bus + + // Wire xray crash callback BEFORE startTask so it's ready + xray.OnCrash = func(err error) { + if s.bus != nil { + s.bus.Publish(eventbus.Event{ + Type: eventbus.EventXrayCrash, + Data: err.Error(), + }) + } + } + + // Register email subscriber (always — it checks smtpEnable at runtime) + emailService := email.NewEmailService(s.settingService) + emailSub := email.NewSubscriber(s.settingService, emailService) + s.bus.Subscribe("email-notifier", emailSub.HandleEvent) + + // Wire email service to controller for test endpoint + controller.SetEmailService(emailService) + + // Wire Telegram test function to controller + controller.SetTestTgFunc(func() error { + if !s.tgbotService.IsRunning() { + return fmt.Errorf("telegram bot is not running (check token and chat ID)") + } + if err := s.tgbotService.TestConnection(); err != nil { + return fmt.Errorf("telegram API test failed: %w", err) + } + s.tgbotService.SendMsgToTgbotAdmins("✅ Test message from 3x-ui") + return nil + }) + s.startTask(restartXray) if startTgBot { @@ -486,6 +555,8 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) { if (err == nil) && (isTgbotenabled) { tgBot := s.tgbotService.NewTgbot() tgBot.Start(i18nFS) + // Subscribe Telegram notifications for event bus + s.bus.Subscribe("tg-notifier", s.tgbotService.HandleEvent) } } @@ -510,6 +581,9 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error { if s.cron != nil { s.cron.Stop() } + if s.bus != nil { + s.bus.Stop() + } if err := service.PersistSystemMetrics(); err != nil { logger.Warning("persist system metrics on shutdown failed:", err) } diff --git a/internal/xray/process.go b/internal/xray/process.go index d63dfc280..76799594e 100644 --- a/internal/xray/process.go +++ b/internal/xray/process.go @@ -213,6 +213,8 @@ func (p *process) SetOnlineAPISupport(v OnlineAPISupport) { var ( xrayGracefulStopTimeout = 5 * time.Second xrayForceStopTimeout = 2 * time.Second + // OnCrash is called when xray crashes unexpectedly. Set from web layer. + OnCrash func(err error) ) // newProcess creates a new internal process struct for Xray. @@ -566,6 +568,9 @@ func (p *process) waitForCommand(cmd *exec.Cmd) { logger.Error("Failure in running xray-core:", err) p.exitErr = err + if OnCrash != nil { + OnCrash(err) + } } // Stop terminates the running Xray process.