8 Комити 3cf3fddf12 ... 29b14dac59

Аутор SHA1 Порука Датум
  MHSanaei 29b14dac59 feat(ci): let mention bot push commits to fork PR branches пре 11 часа
  MHSanaei 4ab2dffa61 fix(ci): check out PR branch for mention bot so commits land on the PR пре 12 часа
  MHSanaei caf80009c8 feat(ci): add PR review job and commit-capable mention bot пре 12 часа
  MHSanaei 0537cbfb10 chore: bump dompurify to 3.4.11 and expand VS Code tasks пре 12 часа
  dependabot[bot] 1eaa73e7c6 chore(deps): bump actions/checkout from 6 to 7 (#5454) пре 13 часа
  Sentiago 55d08d2ae9 feat: replace notification checkboxes with card-based layout (#5421) пре 13 часа
  MHSanaei 1259c20e5f fix(tgbot): dedupe exhausted-client report by email (#5453) пре 13 часа
  n0ctal 2bb29468d8 fix(xray): guard log-writer race and bound handler gRPC deadlines (#5442) пре 17 часа
29 измењених фајлова са 881 додато и 200 уклоњено
  1. 6 6
      .github/workflows/ci.yml
  2. 189 6
      .github/workflows/claude-bot.yml
  3. 1 1
      .github/workflows/codeql.yml
  4. 1 1
      .github/workflows/docker.yml
  5. 2 2
      .github/workflows/image.yml
  6. 1 1
      .github/workflows/mutation.yml
  7. 2 2
      .github/workflows/release.yml
  8. 2 2
      .github/workflows/smoke.yml
  9. 219 4
      .vscode/tasks.json
  10. 3 3
      frontend/package-lock.json
  11. 1 0
      frontend/package.json
  12. 0 147
      frontend/src/components/ui/EventBusCheckboxes.tsx
  13. 0 1
      frontend/src/components/ui/index.ts
  14. 94 0
      frontend/src/components/ui/notifications/EmailNotifications.tsx
  15. 23 0
      frontend/src/components/ui/notifications/NotificationCard.tsx
  16. 26 0
      frontend/src/components/ui/notifications/NotificationEvent.tsx
  17. 60 0
      frontend/src/components/ui/notifications/NotificationGroup.tsx
  18. 27 0
      frontend/src/components/ui/notifications/NotificationHeader.tsx
  19. 13 0
      frontend/src/components/ui/notifications/NotificationLayout.tsx
  20. 94 0
      frontend/src/components/ui/notifications/TelegramNotifications.tsx
  21. 8 0
      frontend/src/components/ui/notifications/index.ts
  22. 14 0
      frontend/src/components/ui/notifications/types.ts
  23. 3 7
      frontend/src/pages/settings/EmailTab.tsx
  24. 3 7
      frontend/src/pages/settings/TelegramTab.tsx
  25. 5 0
      internal/web/service/tgbot/tgbot_report.go
  26. 23 3
      internal/xray/api.go
  27. 22 5
      internal/xray/log_writer.go
  28. 36 0
      internal/xray/log_writer_race_test.go
  29. 3 2
      internal/xray/process.go

+ 6 - 6
.github/workflows/ci.yml

@@ -25,7 +25,7 @@ jobs:
   go-test:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v6
+      - uses: actions/checkout@v7
       - uses: actions/setup-go@v6
         with:
           go-version-file: go.mod
@@ -40,7 +40,7 @@ jobs:
   codegen:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v6
+      - uses: actions/checkout@v7
       - uses: actions/setup-go@v6
         with:
           go-version-file: go.mod
@@ -57,7 +57,7 @@ jobs:
   govulncheck:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v6
+      - uses: actions/checkout@v7
       - uses: actions/setup-go@v6
         with:
           go-version-file: go.mod
@@ -73,7 +73,7 @@ jobs:
   race:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v6
+      - uses: actions/checkout@v7
       - uses: actions/setup-go@v6
         with:
           go-version-file: go.mod
@@ -90,7 +90,7 @@ jobs:
   fuzz-smoke:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v6
+      - uses: actions/checkout@v7
       - uses: actions/setup-go@v6
         with:
           go-version-file: go.mod
@@ -105,7 +105,7 @@ jobs:
   frontend:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v6
+      - uses: actions/checkout@v7
       - uses: actions/setup-node@v6
         with:
           node-version-file: .nvmrc

Разлика између датотеке није приказан због своје велике величине
+ 189 - 6
.github/workflows/claude-bot.yml


+ 1 - 1
.github/workflows/codeql.yml

@@ -45,7 +45,7 @@ jobs:
 
     steps:
       - name: Checkout repository
-        uses: actions/checkout@v6
+        uses: actions/checkout@v7
 
       - name: Setup Node.js
         if: matrix.language == 'go'

+ 1 - 1
.github/workflows/docker.yml

@@ -15,7 +15,7 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-      - uses: actions/checkout@v6
+      - uses: actions/checkout@v7
         with:
           submodules: true
 

+ 2 - 2
.github/workflows/image.yml

@@ -107,7 +107,7 @@ jobs:
     runs-on: ${{ matrix.runner }}
     steps:
       - name: Checkout
-        uses: actions/checkout@v6
+        uses: actions/checkout@v7
 
       - name: Install QEMU
         run: |
@@ -200,7 +200,7 @@ jobs:
             instance_type: t4g.small
     steps:
       - name: Checkout
-        uses: actions/checkout@v6
+        uses: actions/checkout@v7
 
       - name: Setup Packer
         uses: hashicorp/setup-packer@v3

+ 1 - 1
.github/workflows/mutation.yml

@@ -36,7 +36,7 @@ jobs:
             path: ./internal/web/service/
             exclude: 'server\.go|xray\.go|inbound\.go|client_bulk\.go|inbound_traffic\.go|.*_postgres_test\.go'
     steps:
-      - uses: actions/checkout@v6
+      - uses: actions/checkout@v7
       - uses: actions/setup-go@v6
         with:
           go-version-file: go.mod

+ 2 - 2
.github/workflows/release.yml

@@ -44,7 +44,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout repository
-        uses: actions/checkout@v6
+        uses: actions/checkout@v7
 
       - name: Setup Go
         uses: actions/setup-go@v6
@@ -196,7 +196,7 @@ jobs:
     runs-on: windows-latest
     steps:
       - name: Checkout repository
-        uses: actions/checkout@v6
+        uses: actions/checkout@v7
 
       - name: Setup Go
         uses: actions/setup-go@v6

+ 2 - 2
.github/workflows/smoke.yml

@@ -27,7 +27,7 @@ jobs:
     runs-on: ${{ matrix.runner }}
     timeout-minutes: 15
     steps:
-      - uses: actions/checkout@v6
+      - uses: actions/checkout@v7
       - name: Non-interactive install smoke test
         run: bash deploy/test/smoke-noninteractive.sh
 
@@ -39,6 +39,6 @@ jobs:
     runs-on: ${{ matrix.runner }}
     timeout-minutes: 15
     steps:
-      - uses: actions/checkout@v6
+      - uses: actions/checkout@v7
       - name: First-boot credential smoke test
         run: bash deploy/test/smoke-firstboot.sh

+ 219 - 4
.vscode/tasks.json

@@ -74,15 +74,230 @@
     {
       "label": "go: fmt",
       "type": "shell",
-      "command": "gofmt",
+      "command": "go",
+      "args": [
+        "fmt",
+        "./..."
+      ],
+      "options": {
+        "cwd": "${workspaceFolder}"
+      },
+      "problemMatcher": [
+        "$go"
+      ]
+    },
+    {
+      "label": "go: modernize",
+      "type": "shell",
+      "command": "modernize",
       "args": [
-        "-l",
-        "-w",
-        "."
+        "./..."
       ],
       "options": {
         "cwd": "${workspaceFolder}"
       },
+      "problemMatcher": [
+        "$go"
+      ]
+    },
+    {
+      "label": "go: modernize -fix",
+      "type": "shell",
+      "command": "modernize",
+      "args": [
+        "-fix",
+        "./..."
+      ],
+      "options": {
+        "cwd": "${workspaceFolder}"
+      },
+      "problemMatcher": [
+        "$go"
+      ]
+    },
+    {
+      "label": "frontend: ncu -u",
+      "type": "shell",
+      "command": "npx",
+      "args": [
+        "npm-check-updates",
+        "-u"
+      ],
+      "options": {
+        "cwd": "${workspaceFolder}/frontend"
+      },
+      "problemMatcher": []
+    },
+    {
+      "label": "frontend: install",
+      "type": "shell",
+      "command": "npm",
+      "args": [
+        "install"
+      ],
+      "options": {
+        "cwd": "${workspaceFolder}/frontend"
+      },
+      "problemMatcher": []
+    },
+    {
+      "label": "frontend: dev",
+      "type": "shell",
+      "command": "npm",
+      "args": [
+        "run",
+        "dev"
+      ],
+      "options": {
+        "cwd": "${workspaceFolder}/frontend"
+      },
+      "isBackground": true,
+      "problemMatcher": [],
+      "presentation": {
+        "panel": "dedicated",
+        "group": "dev"
+      }
+    },
+    {
+      "label": "frontend: build",
+      "type": "shell",
+      "command": "npm",
+      "args": [
+        "run",
+        "build"
+      ],
+      "options": {
+        "cwd": "${workspaceFolder}/frontend"
+      },
+      "problemMatcher": [
+        "$tsc"
+      ],
+      "group": "build"
+    },
+    {
+      "label": "frontend: gen",
+      "type": "shell",
+      "command": "npm",
+      "args": [
+        "run",
+        "gen"
+      ],
+      "options": {
+        "cwd": "${workspaceFolder}/frontend"
+      },
+      "problemMatcher": []
+    },
+    {
+      "label": "frontend: lint",
+      "type": "shell",
+      "command": "npm",
+      "args": [
+        "run",
+        "lint"
+      ],
+      "options": {
+        "cwd": "${workspaceFolder}/frontend"
+      },
+      "problemMatcher": [
+        "$eslint-stylish"
+      ]
+    },
+    {
+      "label": "frontend: test",
+      "type": "shell",
+      "command": "npm",
+      "args": [
+        "run",
+        "test"
+      ],
+      "options": {
+        "cwd": "${workspaceFolder}/frontend"
+      },
+      "problemMatcher": [],
+      "group": "test"
+    },
+    {
+      "label": "frontend: test:watch",
+      "type": "shell",
+      "command": "npm",
+      "args": [
+        "run",
+        "test:watch"
+      ],
+      "options": {
+        "cwd": "${workspaceFolder}/frontend"
+      },
+      "isBackground": true,
+      "problemMatcher": [],
+      "group": "test",
+      "presentation": {
+        "panel": "dedicated",
+        "group": "test"
+      }
+    },
+    {
+      "label": "frontend: typecheck",
+      "type": "shell",
+      "command": "npm",
+      "args": [
+        "run",
+        "typecheck"
+      ],
+      "options": {
+        "cwd": "${workspaceFolder}/frontend"
+      },
+      "problemMatcher": [
+        "$tsc"
+      ]
+    },
+    {
+      "label": "frontend: gen:zod",
+      "type": "shell",
+      "command": "npm",
+      "args": [
+        "run",
+        "gen:zod"
+      ],
+      "options": {
+        "cwd": "${workspaceFolder}/frontend"
+      },
+      "problemMatcher": []
+    },
+    {
+      "label": "frontend: gen:api",
+      "type": "shell",
+      "command": "npm",
+      "args": [
+        "run",
+        "gen:api"
+      ],
+      "options": {
+        "cwd": "${workspaceFolder}/frontend"
+      },
+      "problemMatcher": []
+    },
+    {
+      "label": "build: full (frontend + go)",
+      "dependsOrder": "sequence",
+      "dependsOn": [
+        "frontend: build",
+        "go: build"
+      ],
+      "problemMatcher": [],
+      "group": {
+        "kind": "build",
+        "isDefault": false
+      }
+    },
+    {
+      "label": "check: all",
+      "dependsOn": [
+        "go: vet",
+        "go: test",
+        "frontend: lint",
+        "frontend: typecheck",
+        "frontend: test"
+      ],
       "problemMatcher": []
     }
   ]

+ 3 - 3
frontend/package-lock.json

@@ -4388,9 +4388,9 @@
       "license": "MIT"
     },
     "node_modules/dompurify": {
-      "version": "3.4.10",
-      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz",
-      "integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==",
+      "version": "3.4.11",
+      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
+      "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
       "license": "(MPL-2.0 OR Apache-2.0)",
       "optionalDependencies": {
         "@types/trusted-types": "^2.0.7"

+ 1 - 0
frontend/package.json

@@ -61,6 +61,7 @@
     "vitest": "^4.1.9"
   },
   "overrides": {
+    "dompurify": "^3.4.11",
     "react-copy-to-clipboard": "^5.1.1",
     "react-inspector": "^9.0.0",
     "react-debounce-input": {

+ 0 - 147
frontend/src/components/ui/EventBusCheckboxes.tsx

@@ -1,147 +0,0 @@
-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<string, { key: string; value: number }>;
-  /** 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: (
-        <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
-          <span style={{ fontWeight: 500 }}>{t(group.labelKey)}</span>
-          <span style={{ color: '#999', fontSize: 12 }}>
-            {count}/{total}
-          </span>
-          <Checkbox
-            checked={allSelected}
-            indeterminate={count > 0 && count < total}
-            onClick={(e) => e.stopPropagation()}
-            onChange={() => toggleGroup(group)}
-          />
-        </div>
-      ),
-      children: (
-        <Checkbox.Group value={selected} style={{ width: '100%' }}>
-          <Space wrap size={[16, 4]}>
-            {group.events.map((et) => {
-              const checked = selected.includes(et.value);
-              const extraConf = extra?.[et.value];
-              return (
-                <span key={et.value} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
-                  <Checkbox value={et.value} onChange={() => toggle(et.value)}>
-                    {t(et.labelKey)}
-                  </Checkbox>
-                  {extraConf && onExtraChange && (
-                    <InputNumber
-                      size="small"
-                      min={0}
-                      max={100}
-                      value={extraConf.value}
-                      disabled={!checked}
-                      onChange={(v) => onExtraChange(extraConf.key, v)}
-                      style={{ width: 60 }}
-                    />
-                  )}
-                </span>
-              );
-            })}
-          </Space>
-        </Checkbox.Group>
-      ),
-    };
-  });
-
-  const defaultActiveKeys = EVENT_GROUPS
-    .filter((g) => g.events.some((e) => selected.includes(e.value)))
-    .map((g) => g.key);
-
-  return (
-    <Collapse
-      items={items}
-      defaultActiveKey={defaultActiveKeys.length > 0 ? defaultActiveKeys : ['outbound']}
-      expandIcon={({ isActive }) => isActive ? <DownOutlined /> : <RightOutlined />}
-      size="small"
-    />
-  );
-}

+ 0 - 1
frontend/src/components/ui/index.ts

@@ -1,4 +1,3 @@
 export { default as InputAddon } from './InputAddon';
 export { default as InfinityIcon } from './InfinityIcon';
 export { default as SettingListItem } from './SettingListItem';
-export { EventBusCheckboxes } from './EventBusCheckboxes';

+ 94 - 0
frontend/src/components/ui/notifications/EmailNotifications.tsx

@@ -0,0 +1,94 @@
+import { InputNumber } from 'antd';
+import { CloudServerOutlined, ThunderboltOutlined, DesktopOutlined, DashboardOutlined, SafetyOutlined } from '@ant-design/icons';
+import type { AllSetting } from '@/models/setting';
+import { NotificationLayout } from './NotificationLayout';
+import { NotificationGroup } from './NotificationGroup';
+import type { NotificationGroupConfig } from './types';
+
+const GROUPS: NotificationGroupConfig[] = [
+  {
+    icon: <CloudServerOutlined />,
+    title: 'eventGroupOutbound',
+    events: [
+      { key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' },
+      { key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
+    ],
+  },
+  {
+    icon: <ThunderboltOutlined />,
+    title: 'eventGroupXray',
+    events: [
+      { key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' },
+    ],
+  },
+  {
+    icon: <DesktopOutlined />,
+    title: 'eventGroupNode',
+    events: [
+      { key: 'node.down', label: 'eventNodeDown', settingKey: '' },
+      { key: 'node.up', label: 'eventNodeUp', settingKey: '' },
+    ],
+  },
+  {
+    icon: <DashboardOutlined />,
+    title: 'eventGroupSystem',
+    events: [
+      {
+        key: 'cpu.high',
+        label: 'eventCPUHigh',
+        settingKey: 'smtpCpu',
+        extra: ({ value, onChange }) => (
+          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
+        ),
+      },
+    ],
+  },
+  {
+    icon: <SafetyOutlined />,
+    title: 'eventGroupSecurity',
+    events: [
+      { key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' },
+    ],
+  },
+];
+
+interface Props {
+  allSetting: AllSetting;
+  updateSetting: (patch: Partial<AllSetting>) => void;
+}
+
+export function EmailNotifications({ allSetting, updateSetting }: Props) {
+  const events = allSetting.smtpEnabledEvents || '';
+  const selected = events ? events.split(',').map((s) => s.trim()).filter(Boolean) : [];
+
+  function toggle(key: string) {
+    const next = selected.includes(key)
+      ? selected.filter((e) => e !== key)
+      : [...selected, key];
+    updateSetting({ smtpEnabledEvents: next.join(',') });
+  }
+
+  function toggleAll(keys: string[]) {
+    const allSelected = keys.every((v) => selected.includes(v));
+    const next = allSelected
+      ? selected.filter((v) => !keys.includes(v))
+      : [...new Set([...selected, ...keys])];
+    updateSetting({ smtpEnabledEvents: next.join(',') });
+  }
+
+  return (
+    <NotificationLayout>
+      {GROUPS.map((group, i) => (
+        <NotificationGroup
+          key={i}
+          config={group}
+          selected={selected}
+          onToggle={toggle}
+          onToggleAll={toggleAll}
+          allSetting={allSetting}
+          updateSetting={updateSetting}
+        />
+      ))}
+    </NotificationLayout>
+  );
+}

+ 23 - 0
frontend/src/components/ui/notifications/NotificationCard.tsx

@@ -0,0 +1,23 @@
+import type { ReactNode } from 'react';
+import { Card } from 'antd';
+
+interface Props {
+  icon: ReactNode;
+  title: ReactNode;
+  extra: ReactNode;
+  children: ReactNode;
+}
+
+export function NotificationCard({ icon, title, extra, children }: Props) {
+  return (
+    <Card
+      size="small"
+      bordered
+      title={<span>{icon} {title}</span>}
+      extra={extra}
+      style={{ borderWidth: 1 }}
+    >
+      {children}
+    </Card>
+  );
+}

+ 26 - 0
frontend/src/components/ui/notifications/NotificationEvent.tsx

@@ -0,0 +1,26 @@
+import type { ReactNode } from 'react';
+import { Checkbox } from 'antd';
+import { useTranslation } from 'react-i18next';
+
+interface Props {
+  label: string;
+  checked: boolean;
+  onToggle: () => void;
+  children?: ReactNode;
+}
+
+export function NotificationEvent({ label, checked, onToggle, children }: Props) {
+  const { t } = useTranslation();
+  return (
+    <div>
+      <Checkbox checked={checked} onChange={onToggle}>
+        {t(label)}
+      </Checkbox>
+      {checked && children && (
+        <div style={{ paddingLeft: 24, marginTop: 4 }}>
+          {children}
+        </div>
+      )}
+    </div>
+  );
+}

+ 60 - 0
frontend/src/components/ui/notifications/NotificationGroup.tsx

@@ -0,0 +1,60 @@
+import { Space } from 'antd';
+import { useTranslation } from 'react-i18next';
+import type { AllSetting } from '@/models/setting';
+import type { NotificationGroupConfig } from './types';
+import { NotificationCard } from './NotificationCard';
+import { NotificationHeader } from './NotificationHeader';
+import { NotificationEvent } from './NotificationEvent';
+
+interface Props {
+  config: NotificationGroupConfig;
+  selected: string[];
+  onToggle: (key: string) => void;
+  onToggleAll: (keys: string[]) => void;
+  allSetting: AllSetting;
+  updateSetting: (patch: Partial<AllSetting>) => void;
+}
+
+export function NotificationGroup({ config, selected, onToggle, onToggleAll, allSetting, updateSetting }: Props) {
+  const { t } = useTranslation();
+
+  const count = config.events.filter((e) => selected.includes(e.key)).length;
+  const total = config.events.length;
+
+  function toggleAll() {
+    const values = config.events.map((e) => e.key);
+    onToggleAll(values);
+  }
+
+  return (
+    <NotificationCard
+      icon={config.icon}
+      title={t(`pages.settings.${config.title}`)}
+      extra={
+        <NotificationHeader
+          count={count}
+          total={total}
+          allSelected={count === total}
+          indeterminate={count > 0 && count < total}
+          onToggleAll={toggleAll}
+        />
+      }
+    >
+      <Space direction="vertical" size={8} style={{ width: '100%' }}>
+        {config.events.map((event) => (
+          <NotificationEvent
+            key={event.key}
+            label={t(`pages.settings.${event.label}`)}
+            checked={selected.includes(event.key)}
+            onToggle={() => onToggle(event.key)}
+          >
+            {event.extra?.({
+              value: Number((allSetting as unknown as Record<string, unknown>)[event.settingKey]) || 0,
+              onChange: (v) => updateSetting({ [event.settingKey]: v }),
+            })}
+          </NotificationEvent>
+        ))}
+      </Space>
+    </NotificationCard>
+  );
+}

+ 27 - 0
frontend/src/components/ui/notifications/NotificationHeader.tsx

@@ -0,0 +1,27 @@
+import { useRef, useEffect } from 'react';
+import { Tag } from 'antd';
+
+interface Props {
+  count: number;
+  total: number;
+  allSelected: boolean;
+  indeterminate: boolean;
+  onToggleAll: () => void;
+}
+
+function MasterCheckbox({ checked, indeterminate, onChange }: { checked: boolean; indeterminate: boolean; onChange: () => void }) {
+  const ref = useRef<HTMLInputElement>(null);
+  useEffect(() => {
+    if (ref.current) ref.current.indeterminate = indeterminate;
+  }, [indeterminate]);
+  return <input ref={ref} type="checkbox" checked={checked} onChange={onChange} style={{ cursor: 'pointer' }} />;
+}
+
+export function NotificationHeader({ count, total, allSelected, indeterminate, onToggleAll }: Props) {
+  return (
+    <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
+      <Tag>{count}/{total}</Tag>
+      <MasterCheckbox checked={allSelected} indeterminate={indeterminate} onChange={onToggleAll} />
+    </span>
+  );
+}

+ 13 - 0
frontend/src/components/ui/notifications/NotificationLayout.tsx

@@ -0,0 +1,13 @@
+import type { ReactNode } from 'react';
+
+interface Props {
+  children: ReactNode;
+}
+
+export function NotificationLayout({ children }: Props) {
+  return (
+    <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 12 }}>
+      {children}
+    </div>
+  );
+}

+ 94 - 0
frontend/src/components/ui/notifications/TelegramNotifications.tsx

@@ -0,0 +1,94 @@
+import { InputNumber } from 'antd';
+import { CloudServerOutlined, ThunderboltOutlined, DesktopOutlined, DashboardOutlined, SafetyOutlined } from '@ant-design/icons';
+import type { AllSetting } from '@/models/setting';
+import { NotificationLayout } from './NotificationLayout';
+import { NotificationGroup } from './NotificationGroup';
+import type { NotificationGroupConfig } from './types';
+
+const GROUPS: NotificationGroupConfig[] = [
+  {
+    icon: <CloudServerOutlined />,
+    title: 'eventGroupOutbound',
+    events: [
+      { key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' },
+      { key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
+    ],
+  },
+  {
+    icon: <ThunderboltOutlined />,
+    title: 'eventGroupXray',
+    events: [
+      { key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' },
+    ],
+  },
+  {
+    icon: <DesktopOutlined />,
+    title: 'eventGroupNode',
+    events: [
+      { key: 'node.down', label: 'eventNodeDown', settingKey: '' },
+      { key: 'node.up', label: 'eventNodeUp', settingKey: '' },
+    ],
+  },
+  {
+    icon: <DashboardOutlined />,
+    title: 'eventGroupSystem',
+    events: [
+      {
+        key: 'cpu.high',
+        label: 'eventCPUHigh',
+        settingKey: 'tgCpu',
+        extra: ({ value, onChange }) => (
+          <InputNumber size="small" min={0} max={100} value={value} onChange={onChange} style={{ width: 80 }} />
+        ),
+      },
+    ],
+  },
+  {
+    icon: <SafetyOutlined />,
+    title: 'eventGroupSecurity',
+    events: [
+      { key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' },
+    ],
+  },
+];
+
+interface Props {
+  allSetting: AllSetting;
+  updateSetting: (patch: Partial<AllSetting>) => void;
+}
+
+export function TelegramNotifications({ allSetting, updateSetting }: Props) {
+  const events = allSetting.tgEnabledEvents || '';
+  const selected = events ? events.split(',').map((s) => s.trim()).filter(Boolean) : [];
+
+  function toggle(key: string) {
+    const next = selected.includes(key)
+      ? selected.filter((e) => e !== key)
+      : [...selected, key];
+    updateSetting({ tgEnabledEvents: next.join(',') });
+  }
+
+  function toggleAll(keys: string[]) {
+    const allSelected = keys.every((v) => selected.includes(v));
+    const next = allSelected
+      ? selected.filter((v) => !keys.includes(v))
+      : [...new Set([...selected, ...keys])];
+    updateSetting({ tgEnabledEvents: next.join(',') });
+  }
+
+  return (
+    <NotificationLayout>
+      {GROUPS.map((group, i) => (
+        <NotificationGroup
+          key={i}
+          config={group}
+          selected={selected}
+          onToggle={toggle}
+          onToggleAll={toggleAll}
+          allSetting={allSetting}
+          updateSetting={updateSetting}
+        />
+      ))}
+    </NotificationLayout>
+  );
+}

+ 8 - 0
frontend/src/components/ui/notifications/index.ts

@@ -0,0 +1,8 @@
+export type { NotificationEventConfig, NotificationGroupConfig } from './types';
+export { NotificationLayout } from './NotificationLayout';
+export { NotificationCard } from './NotificationCard';
+export { NotificationHeader } from './NotificationHeader';
+export { NotificationEvent } from './NotificationEvent';
+export { NotificationGroup } from './NotificationGroup';
+export { TelegramNotifications } from './TelegramNotifications';
+export { EmailNotifications } from './EmailNotifications';

+ 14 - 0
frontend/src/components/ui/notifications/types.ts

@@ -0,0 +1,14 @@
+import type { ReactNode } from 'react';
+
+export interface NotificationEventConfig {
+  key: string;
+  label: string;
+  settingKey: string;
+  extra?: (props: { value: number; onChange: (v: number | null) => void }) => ReactNode;
+}
+
+export interface NotificationGroupConfig {
+  icon: ReactNode;
+  title: string;
+  events: NotificationEventConfig[];
+}

+ 3 - 7
frontend/src/pages/settings/EmailTab.tsx

@@ -4,7 +4,8 @@ import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from '
 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 { SettingListItem } from '@/components/ui';
+import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
 
@@ -122,12 +123,7 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
         children: (
           <>
             <SettingListItem paddings="small" title={t('pages.settings.smtpEventBusNotify')} description={t('pages.settings.smtpEventBusNotifyDesc')}>
-              <EventBusCheckboxes
-                value={allSetting.smtpEnabledEvents}
-                onChange={(v) => updateSetting({ smtpEnabledEvents: v })}
-                extra={{ 'cpu.high': { key: 'smtpCpu', value: allSetting.smtpCpu } }}
-                onExtraChange={(key, v) => updateSetting({ [key]: Number(v) || 0 })}
-              />
+              <EmailNotifications allSetting={allSetting} updateSetting={updateSetting} />
             </SettingListItem>
           </>
         ),

+ 3 - 7
frontend/src/pages/settings/TelegramTab.tsx

@@ -5,7 +5,8 @@ import { BellOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
 import { LanguageManager } from '@/utils';
 import { HttpUtil } from '@/utils';
 import type { AllSetting } from '@/models/setting';
-import { SettingListItem, EventBusCheckboxes } from '@/components/ui';
+import { SettingListItem } from '@/components/ui';
+import { TelegramNotifications } from '@/components/ui/notifications/TelegramNotifications';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
 
@@ -245,12 +246,7 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
             </SettingListItem>
 
             <SettingListItem paddings="small" title={t('pages.settings.tgEventBusNotify')} description={t('pages.settings.tgEventBusNotifyDesc')}>
-              <EventBusCheckboxes
-                value={allSetting.tgEnabledEvents}
-                onChange={(v) => updateSetting({ tgEnabledEvents: v })}
-                extra={{ 'cpu.high': { key: 'tgCpu', value: allSetting.tgCpu } }}
-                onExtraChange={(key, v) => updateSetting({ [key]: Number(v) || 0 })}
-              />
+              <TelegramNotifications allSetting={allSetting} updateSetting={updateSetting} />
             </SettingListItem>
           </>
         ),

+ 5 - 0
internal/web/service/tgbot/tgbot_report.go

@@ -206,6 +206,7 @@ func (t *Tgbot) getExhausted(chatId int64) {
 		logger.Warning("Unable to load Inbounds", err)
 	}
 
+	seenClients := make(map[string]bool)
 	for _, inbound := range inbounds {
 		if inbound.Enable {
 			if (inbound.ExpiryTime > 0 && (inbound.ExpiryTime-now < exDiff)) ||
@@ -214,6 +215,10 @@ func (t *Tgbot) getExhausted(chatId int64) {
 			}
 			if len(inbound.ClientStats) > 0 {
 				for _, client := range inbound.ClientStats {
+					if seenClients[client.Email] {
+						continue
+					}
+					seenClients[client.Email] = true
 					if client.Enable {
 						if (client.ExpiryTime > 0 && (client.ExpiryTime-now < exDiff)) ||
 							(client.Total > 0 && (client.Total-(client.Up+client.Down) < trDiff)) {

+ 23 - 3
internal/xray/api.go

@@ -123,8 +123,16 @@ func (x *XrayAPI) Close() {
 	x.isConnected = false
 }
 
+// handlerRPCTimeout bounds per-call gRPC handler operations (add/remove inbound,
+// alter user) so a hung core connection cannot block the caller indefinitely —
+// for example while the process restart lock is held.
+const handlerRPCTimeout = 10 * time.Second
+
 // AddInbound adds a new inbound configuration to the Xray core via gRPC.
 func (x *XrayAPI) AddInbound(inbound []byte) error {
+	if x.HandlerServiceClient == nil {
+		return common.NewError("xray HandlerServiceClient is not initialized")
+	}
 	client := *x.HandlerServiceClient
 
 	conf := new(conf.InboundDetourConfig)
@@ -140,15 +148,22 @@ func (x *XrayAPI) AddInbound(inbound []byte) error {
 	}
 	inboundConfig := command.AddInboundRequest{Inbound: config}
 
-	_, err = client.AddInbound(context.Background(), &inboundConfig)
+	ctx, cancel := context.WithTimeout(context.Background(), handlerRPCTimeout)
+	defer cancel()
+	_, err = client.AddInbound(ctx, &inboundConfig)
 
 	return err
 }
 
 // DelInbound removes an inbound configuration from the Xray core by tag.
 func (x *XrayAPI) DelInbound(tag string) error {
+	if x.HandlerServiceClient == nil {
+		return common.NewError("xray HandlerServiceClient is not initialized")
+	}
 	client := *x.HandlerServiceClient
-	_, err := client.RemoveInbound(context.Background(), &command.RemoveInboundRequest{
+	ctx, cancel := context.WithTimeout(context.Background(), handlerRPCTimeout)
+	defer cancel()
+	_, err := client.RemoveInbound(ctx, &command.RemoveInboundRequest{
 		Tag: tag,
 	})
 	return err
@@ -505,9 +520,14 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
 		return nil
 	}
 
+	if x.HandlerServiceClient == nil {
+		return common.NewError("xray HandlerServiceClient is not initialized")
+	}
 	client := *x.HandlerServiceClient
 
-	_, err = client.AlterInbound(context.Background(), &command.AlterInboundRequest{
+	ctx, cancel := context.WithTimeout(context.Background(), handlerRPCTimeout)
+	defer cancel()
+	_, err = client.AlterInbound(ctx, &command.AlterInboundRequest{
 		Tag: inboundTag,
 		Operation: serial.ToTypedMessage(&command.AddUserOperation{
 			User: &protocol.User{

+ 22 - 5
internal/xray/log_writer.go

@@ -4,6 +4,7 @@ import (
 	"regexp"
 	"runtime"
 	"strings"
+	"sync"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 )
@@ -22,9 +23,25 @@ func NewLogWriter() *LogWriter {
 
 // LogWriter processes and filters log output from the Xray process, handling crash detection and message filtering.
 type LogWriter struct {
+	mu       sync.RWMutex
 	lastLine string
 }
 
+// LastLine returns the most recently processed Xray log line. It is safe for
+// concurrent use: Process.GetResult reads it from a different goroutine than the
+// one Xray drives Write from.
+func (lw *LogWriter) LastLine() string {
+	lw.mu.RLock()
+	defer lw.mu.RUnlock()
+	return lw.lastLine
+}
+
+func (lw *LogWriter) setLastLine(line string) {
+	lw.mu.Lock()
+	lw.lastLine = line
+	lw.mu.Unlock()
+}
+
 // Write processes and filters log output from the Xray process, handling crash detection and message filtering.
 func (lw *LogWriter) Write(m []byte) (n int, err error) {
 	// Convert the data to a string
@@ -39,7 +56,7 @@ func (lw *LogWriter) Write(m []byte) (n int, err error) {
 	// Check if the message contains a crash
 	if crashRegex.MatchString(message) {
 		logger.Debug("Core crash detected:\n", message)
-		lw.lastLine = message
+		lw.setLastLine(message)
 		err1 := writeCrashReport(m)
 		if err1 != nil {
 			logger.Error("Unable to write crash report:", err1)
@@ -60,7 +77,7 @@ func (lw *LogWriter) Write(m []byte) (n int, err error) {
 			if strings.Contains(msgBodyLower, "tls handshake error") ||
 				strings.Contains(msgBodyLower, "connection ends") {
 				logger.Debug("XRAY: " + msgBody)
-				lw.lastLine = ""
+				lw.setLastLine("")
 				continue
 			}
 
@@ -80,14 +97,14 @@ func (lw *LogWriter) Write(m []byte) (n int, err error) {
 					logger.Debug("XRAY: " + msg)
 				}
 			}
-			lw.lastLine = ""
+			lw.setLastLine("")
 		} else if msg != "" {
 			msgLower := strings.ToLower(msg)
 
 			if strings.Contains(msgLower, "tls handshake error") ||
 				strings.Contains(msgLower, "connection ends") {
 				logger.Debug("XRAY: " + msg)
-				lw.lastLine = msg
+				lw.setLastLine(msg)
 				continue
 			}
 
@@ -96,7 +113,7 @@ func (lw *LogWriter) Write(m []byte) (n int, err error) {
 			} else {
 				logger.Debug("XRAY: " + msg)
 			}
-			lw.lastLine = msg
+			lw.setLastLine(msg)
 		}
 	}
 

+ 36 - 0
internal/xray/log_writer_race_test.go

@@ -0,0 +1,36 @@
+package xray
+
+import (
+	"sync"
+	"testing"
+)
+
+// TestLogWriterLastLineConcurrent exercises the LogWriter from multiple
+// goroutines: Xray drives Write while another goroutine (Process.GetResult)
+// reads the last line. Run under `go test -race` this fails on an unguarded
+// lastLine field and passes once the access is serialized.
+func TestLogWriterLastLineConcurrent(t *testing.T) {
+	lw := NewLogWriter()
+	const writers, readers, iterations = 4, 4, 500
+
+	var wg sync.WaitGroup
+	wg.Add(writers + readers)
+
+	for i := 0; i < writers; i++ {
+		go func() {
+			defer wg.Done()
+			for j := 0; j < iterations; j++ {
+				_, _ = lw.Write([]byte("2024/01/01 00:00:00.000000 [Info] connection accepted"))
+			}
+		}()
+	}
+	for i := 0; i < readers; i++ {
+		go func() {
+			defer wg.Done()
+			for j := 0; j < iterations; j++ {
+				_ = lw.LastLine()
+			}
+		}()
+	}
+	wg.Wait()
+}

+ 3 - 2
internal/xray/process.go

@@ -273,10 +273,11 @@ func (p *process) GetResult() string {
 	p.mu.RLock()
 	exitErr := p.exitErr
 	p.mu.RUnlock()
-	if len(p.logWriter.lastLine) == 0 && exitErr != nil {
+	lastLine := p.logWriter.LastLine()
+	if len(lastLine) == 0 && exitErr != nil {
 		return exitErr.Error()
 	}
-	return p.logWriter.lastLine
+	return lastLine
 }
 
 // GetVersion returns the version string of the Xray process.

Неке датотеке нису приказане због велике количине промена