Просмотр исходного кода

feat(online): use xray online-stats API for onlines and access-log-free IP limit

Adopt xray-core's statsUserOnline policy and GetUsersStats RPC so online
detection is connection-based and IP limiting no longer requires an access
log. Falls back to the legacy traffic-delta onlines and access-log parsing
when the running core lacks the RPCs (Unimplemented), probed lazily per
process so a panel-driven version switch re-evaluates automatically.

Backend:
- xray/api.go: GetOnlineUsers (one GetUsersStats call returns all online
  users and their source IPs) and IsUnimplementedErr.
- xray/process.go: per-process OnlineAPISupport tri-state capability cache.
- service/xray.go: ensureStatsPolicy injects statsUserOnline into every
  policy level of the generated config; XrayService.GetOnlineUsers probes
  and falls back.
- job/xray_traffic_job.go: union API onlines into the delta-derived active
  set; bump last_online for idle-but-connected clients.
- job/check_client_ip_job.go: API-first IP source with shared enforcement;
  live observations bypass the 30-min stale cutoff; access-log path
  unchanged for older cores.
- service/setting.go: GetIpLimitEnable always true; new accessLogEnable
  default for features that genuinely read the access log.

Frontend:
- Client form split into Basic and Config tabs; IP Limit and IP Log no
  longer gated on access log; compact Auto Renew next to Start After First
  Use; tabBasic/tabConfig added to all 13 locales.
- Xray logs button on the dashboard now gated on accessLogEnable.
MHSanaei 15 часов назад
Родитель
Сommit
7bcc5830c6
33 измененных файлов с 796 добавлено и 291 удалено
  1. 1 0
      frontend/src/generated/types.ts
  2. 3 0
      frontend/src/generated/zod.ts
  3. 3 7
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  4. 227 210
      frontend/src/pages/clients/ClientFormModal.tsx
  5. 1 3
      frontend/src/pages/clients/ClientsPage.tsx
  6. 4 4
      frontend/src/pages/index/IndexPage.tsx
  7. 5 3
      frontend/src/pages/index/XrayStatusCard.tsx
  8. 1 0
      frontend/src/schemas/defaults.ts
  9. 101 27
      internal/web/job/check_client_ip_job.go
  10. 2 2
      internal/web/job/check_client_ip_job_integration_test.go
  11. 65 5
      internal/web/job/check_client_ip_job_test.go
  12. 30 7
      internal/web/job/xray_traffic_job.go
  13. 22 0
      internal/web/service/inbound_traffic.go
  14. 31 19
      internal/web/service/setting.go
  15. 83 0
      internal/web/service/xray.go
  16. 65 0
      internal/web/service/xray_config_inject_test.go
  17. 2 0
      internal/web/translation/ar-EG.json
  18. 2 0
      internal/web/translation/en-US.json
  19. 2 0
      internal/web/translation/es-ES.json
  20. 2 0
      internal/web/translation/fa-IR.json
  21. 2 0
      internal/web/translation/id-ID.json
  22. 2 0
      internal/web/translation/ja-JP.json
  23. 2 0
      internal/web/translation/pt-BR.json
  24. 2 0
      internal/web/translation/ru-RU.json
  25. 2 0
      internal/web/translation/tr-TR.json
  26. 2 0
      internal/web/translation/uk-UA.json
  27. 2 0
      internal/web/translation/vi-VN.json
  28. 2 0
      internal/web/translation/zh-CN.json
  29. 2 0
      internal/web/translation/zh-TW.json
  30. 60 2
      internal/xray/api.go
  31. 19 2
      internal/xray/api_e2e_test.go
  32. 18 0
      internal/xray/online_test.go
  33. 29 0
      internal/xray/process.go

+ 1 - 0
frontend/src/generated/types.ts

@@ -1,4 +1,5 @@
 // Code generated by tools/openapigen. DO NOT EDIT.
+export type OnlineAPISupport = number;
 export type ProcessState = string;
 export type Protocol = string;
 export type SubLinkProvider = unknown;

+ 3 - 0
frontend/src/generated/zod.ts

@@ -1,5 +1,8 @@
 // Code generated by tools/openapigen. DO NOT EDIT.
 import { z } from 'zod';
+export const OnlineAPISupportSchema = z.number().int();
+export type OnlineAPISupport = z.infer<typeof OnlineAPISupportSchema>;
+
 export const ProcessStateSchema = z.string();
 export type ProcessState = z.infer<typeof ProcessStateSchema>;
 

+ 3 - 7
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -21,7 +21,6 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
 interface ClientBulkAddModalProps {
   open: boolean;
   inbounds: InboundOption[];
-  ipLimitEnable?: boolean;
   groups?: string[];
   onOpenChange: (open: boolean) => void;
   onSaved?: () => void;
@@ -52,7 +51,6 @@ function emptyForm(): FormState {
 export default function ClientBulkAddModal({
   open,
   inbounds,
-  ipLimitEnable = false,
   groups = [],
   onOpenChange,
   onSaved,
@@ -316,11 +314,9 @@ export default function ClientBulkAddModal({
             </Form.Item>
           )}
 
-          {ipLimitEnable && (
-            <Form.Item label={t('pages.clients.limitIp')}>
-              <InputNumber value={form.limitIp} min={0} onChange={(v) => update('limitIp', Number(v) || 0)} />
-            </Form.Item>
-          )}
+          <Form.Item label={t('pages.clients.limitIp')}>
+            <InputNumber value={form.limitIp} min={0} onChange={(v) => update('limitIp', Number(v) || 0)} />
+          </Form.Item>
 
           <Form.Item label={t('pages.clients.totalGB')}>
             <InputNumber value={form.totalGB} min={0} step={1} onChange={(v) => update('totalGB', Number(v) || 0)} />

+ 227 - 210
frontend/src/pages/clients/ClientFormModal.tsx

@@ -12,6 +12,7 @@ import {
   Select,
   Space,
   Switch,
+  Tabs,
   Tag,
   message,
 } from 'antd';
@@ -64,7 +65,6 @@ interface ClientFormModalProps {
   client: ClientRecord | null;
   inbounds: InboundOption[];
   attachedIds?: number[];
-  ipLimitEnable?: boolean;
   tgBotEnable?: boolean;
   groups?: string[];
   save: (
@@ -136,7 +136,6 @@ export default function ClientFormModal({
   client,
   inbounds,
   attachedIds = [],
-  ipLimitEnable = false,
   tgBotEnable = false,
   groups = [],
   save,
@@ -424,214 +423,232 @@ export default function ClientFormModal({
         onCancel={close}
       >
         <Form layout="vertical">
-          <Row gutter={16}>
-            <Col xs={24} md={12}>
-              <Form.Item label={t('pages.clients.email')} required>
-                <Space.Compact style={{ display: 'flex' }}>
-                  <Input
-                    value={form.email}
-                    placeholder={t('pages.clients.email')}
-                    style={{ flex: 1 }}
-                    onChange={(e) => update('email', e.target.value)}
-                  />
-                  <Button icon={<ReloadOutlined />} onClick={() => update('email', RandomUtil.randomLowerAndNum(12))} />
-                </Space.Compact>
-              </Form.Item>
-            </Col>
-            <Col xs={24} md={12}>
-              <Form.Item label={t('pages.clients.subId')}>
-                <Space.Compact style={{ display: 'flex' }}>
-                  <Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
-                  <Button icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
-                </Space.Compact>
-              </Form.Item>
-            </Col>
-          </Row>
-
-          <Row gutter={16}>
-            <Col xs={24} md={12}>
-              <Form.Item label={t('pages.clients.hysteriaAuth')}>
-                <Space.Compact style={{ display: 'flex' }}>
-                  <Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
-                  <Button icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
-                </Space.Compact>
-              </Form.Item>
-            </Col>
-            <Col xs={24} md={12}>
-              <Form.Item label={t('pages.clients.password')}>
-                <Space.Compact style={{ display: 'flex' }}>
-                  <Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
-                  <Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
-                </Space.Compact>
-              </Form.Item>
-            </Col>
-          </Row>
-
-          <Row gutter={16}>
-            <Col xs={24} md={12}>
-              <Form.Item label={t('pages.clients.uuid')}>
-                <Space.Compact style={{ display: 'flex' }}>
-                  <Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
-                  <Button icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
-                </Space.Compact>
-              </Form.Item>
-            </Col>
-            <Col xs={24} md={ipLimitEnable ? 8 : 12}>
-              <Form.Item label={t('pages.clients.totalGB')}>
-                <InputNumber value={form.totalGB} min={0} step={1} style={{ width: '100%' }}
-                  onChange={(v) => update('totalGB', Number(v) || 0)} />
-              </Form.Item>
-            </Col>
-            {ipLimitEnable && (
-              <Col xs={24} md={4}>
-                <Form.Item label={t('pages.clients.limitIp')}>
-                  <InputNumber value={form.limitIp} min={0} style={{ width: '100%' }}
-                    onChange={(v) => update('limitIp', Number(v) || 0)} />
-                </Form.Item>
-              </Col>
-            )}
-          </Row>
-
-          <Row gutter={16}>
-            <Col xs={24} md={12}>
-              {form.delayedStart ? (
-                <Form.Item label={t('pages.clients.expireDays')}>
-                  <InputNumber value={form.delayedDays} min={0} style={{ width: '100%' }}
-                    onChange={(v) => update('delayedDays', Number(v) || 0)} />
-                </Form.Item>
-              ) : (
-                <Form.Item label={t('pages.clients.expiryTime')}>
-                  <DateTimePicker
-                    value={form.expiryDate}
-                    onChange={(d) => update('expiryDate', d || null)}
-                  />
-                </Form.Item>
-              )}
-            </Col>
-            <Col xs={24} md={12}>
-              <Form.Item label={t('pages.clients.delayedStart')}>
-                <Switch
-                  checked={form.delayedStart}
-                  onChange={(v) => {
-                    update('delayedStart', v);
-                    if (v) update('expiryDate', null);
-                    else update('delayedDays', 0);
-                  }}
-                />
-              </Form.Item>
-            </Col>
-          </Row>
-
-          <Row gutter={16}>
-            <Col xs={24} md={12}>
-              <Form.Item
-                label={t('pages.clients.renew')}
-                tooltip={t('pages.clients.renewDesc')}
-              >
-                <InputNumber value={form.reset} min={0} style={{ width: '100%' }}
-                  onChange={(v) => update('reset', Number(v) || 0)} />
-              </Form.Item>
-            </Col>
-            {showReverseTag && (
-              <Col xs={24} md={12}>
-                <Form.Item label={t('pages.clients.reverseTag')}>
-                  <Input value={form.reverseTag} placeholder={t('pages.clients.reverseTagPlaceholder')}
-                    onChange={(e) => update('reverseTag', e.target.value)} />
-                </Form.Item>
-              </Col>
-            )}
-            {showFlow && (
-              <Col xs={24} md={12}>
-                <Form.Item label={t('pages.clients.flow')}>
-                  <Select
-                    value={form.flow}
-                    onChange={(v) => update('flow', v)}
-                    options={[
-                      { value: '', label: t('none') },
-                      ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
-                    ]}
-                  />
-                </Form.Item>
-              </Col>
-            )}
-            {showSecurity && (
-              <Col xs={24} md={12}>
-                <Form.Item label={t('pages.clients.vmessSecurity')}>
-                  <Select
-                    value={form.security}
-                    onChange={(v) => update('security', v)}
-                    options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
-                  />
-                </Form.Item>
-              </Col>
-            )}
-          </Row>
-
-          <Row gutter={16}>
-            {tgBotEnable && (
-              <Col xs={24} md={12}>
-                <Form.Item label={t('pages.clients.telegramId')}>
-                  <InputNumber value={form.tgId} min={0} controls={false}
-                    placeholder={t('pages.clients.telegramIdPlaceholder')} style={{ width: '100%' }}
-                    onChange={(v) => update('tgId', Number(v) || 0)} />
-                </Form.Item>
-              </Col>
-            )}
-            <Col xs={24} md={tgBotEnable ? 12 : 24}>
-              <Form.Item label={t('pages.clients.comment')}>
-                <Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
-              </Form.Item>
-            </Col>
-            <Col xs={24} md={12}>
-              <Form.Item label={t('pages.clients.group')} tooltip={t('pages.clients.groupDesc')}>
-                <AutoComplete
-                  value={form.group}
-                  placeholder={t('pages.clients.groupPlaceholder')}
-                  options={groups.map((g) => ({ value: g }))}
-                  onChange={(v) => update('group', v ?? '')}
-                  filterOption={(input, option) =>
-                    String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase())
-                  }
-                  allowClear
-                  style={{ width: '100%' }}
-                />
-              </Form.Item>
-            </Col>
-          </Row>
-
-          <Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
-            <SelectAllClearButtons
-              options={inboundOptions}
-              value={form.inboundIds}
-              onChange={(v) => update('inboundIds', v)}
-            />
-            <Select
-              mode="multiple"
-              value={form.inboundIds}
-              onChange={(v) => update('inboundIds', v)}
-              options={inboundOptions}
-              placeholder={t('pages.clients.selectInbound')}
-              maxTagCount="responsive"
-              placement="topLeft"
-              listHeight={220}
-              showSearch={{
-                filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
-              }}
-            />
-          </Form.Item>
-
-          <Form.Item>
-            <Switch checked={form.enable} onChange={(v) => update('enable', v)} />
-            <span style={{ marginLeft: 8 }}>{t('enable')}</span>
-          </Form.Item>
-
-          {isEdit && ipLimitEnable && (
-            <Form.Item label={t('pages.clients.ipLog')}>
-              <Button icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
-                {clientIps.length > 0 ? clientIps.length : ''}
-              </Button>
-            </Form.Item>
-          )}
+          <Tabs
+            defaultActiveKey="basic"
+            items={[
+              {
+                key: 'basic',
+                label: t('pages.clients.tabBasic'),
+                children: (
+                  <>
+                    <Row gutter={16}>
+                      <Col xs={24} md={12}>
+                        <Form.Item label={t('pages.clients.email')} required>
+                          <Space.Compact style={{ display: 'flex' }}>
+                            <Input
+                              value={form.email}
+                              placeholder={t('pages.clients.email')}
+                              style={{ flex: 1 }}
+                              onChange={(e) => update('email', e.target.value)}
+                            />
+                            <Button icon={<ReloadOutlined />} onClick={() => update('email', RandomUtil.randomLowerAndNum(12))} />
+                          </Space.Compact>
+                        </Form.Item>
+                      </Col>
+                      <Col xs={24} md={8}>
+                        <Form.Item label={t('pages.clients.totalGB')}>
+                          <InputNumber value={form.totalGB} min={0} step={1} style={{ width: '100%' }}
+                            onChange={(v) => update('totalGB', Number(v) || 0)} />
+                        </Form.Item>
+                      </Col>
+                      <Col xs={24} md={4}>
+                        <Form.Item label={t('pages.clients.limitIp')}>
+                          <InputNumber value={form.limitIp} min={0} style={{ width: '100%' }}
+                            onChange={(v) => update('limitIp', Number(v) || 0)} />
+                        </Form.Item>
+                      </Col>
+                    </Row>
+
+                    <Row gutter={16}>
+                      <Col xs={24} md={12}>
+                        {form.delayedStart ? (
+                          <Form.Item label={t('pages.clients.expireDays')}>
+                            <InputNumber value={form.delayedDays} min={0} style={{ width: '100%' }}
+                              onChange={(v) => update('delayedDays', Number(v) || 0)} />
+                          </Form.Item>
+                        ) : (
+                          <Form.Item label={t('pages.clients.expiryTime')}>
+                            <DateTimePicker
+                              value={form.expiryDate}
+                              onChange={(d) => update('expiryDate', d || null)}
+                            />
+                          </Form.Item>
+                        )}
+                      </Col>
+                      <Col xs={12} md={6}>
+                        <Form.Item label={t('pages.clients.delayedStart')}>
+                          <Switch
+                            checked={form.delayedStart}
+                            onChange={(v) => {
+                              update('delayedStart', v);
+                              if (v) update('expiryDate', null);
+                              else update('delayedDays', 0);
+                            }}
+                          />
+                        </Form.Item>
+                      </Col>
+                      <Col xs={12} md={6}>
+                        <Form.Item
+                          label={t('pages.clients.renew')}
+                          tooltip={t('pages.clients.renewDesc')}
+                        >
+                          <InputNumber value={form.reset} min={0} style={{ width: '100%' }}
+                            onChange={(v) => update('reset', Number(v) || 0)} />
+                        </Form.Item>
+                      </Col>
+                    </Row>
+
+                    <Row gutter={16}>
+                      {tgBotEnable && (
+                        <Col xs={24} md={12}>
+                          <Form.Item label={t('pages.clients.telegramId')}>
+                            <InputNumber value={form.tgId} min={0} controls={false}
+                              placeholder={t('pages.clients.telegramIdPlaceholder')} style={{ width: '100%' }}
+                              onChange={(v) => update('tgId', Number(v) || 0)} />
+                          </Form.Item>
+                        </Col>
+                      )}
+                      <Col xs={24} md={tgBotEnable ? 12 : 24}>
+                        <Form.Item label={t('pages.clients.comment')}>
+                          <Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
+                        </Form.Item>
+                      </Col>
+                      <Col xs={24} md={12}>
+                        <Form.Item label={t('pages.clients.group')} tooltip={t('pages.clients.groupDesc')}>
+                          <AutoComplete
+                            value={form.group}
+                            placeholder={t('pages.clients.groupPlaceholder')}
+                            options={groups.map((g) => ({ value: g }))}
+                            onChange={(v) => update('group', v ?? '')}
+                            filterOption={(input, option) =>
+                              String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase())
+                            }
+                            allowClear
+                            style={{ width: '100%' }}
+                          />
+                        </Form.Item>
+                      </Col>
+                    </Row>
+
+                    <Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
+                      <SelectAllClearButtons
+                        options={inboundOptions}
+                        value={form.inboundIds}
+                        onChange={(v) => update('inboundIds', v)}
+                      />
+                      <Select
+                        mode="multiple"
+                        value={form.inboundIds}
+                        onChange={(v) => update('inboundIds', v)}
+                        options={inboundOptions}
+                        placeholder={t('pages.clients.selectInbound')}
+                        maxTagCount="responsive"
+                        placement="topLeft"
+                        listHeight={220}
+                        showSearch={{
+                          filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
+                        }}
+                      />
+                    </Form.Item>
+
+                    <Form.Item>
+                      <Switch checked={form.enable} onChange={(v) => update('enable', v)} />
+                      <span style={{ marginLeft: 8 }}>{t('enable')}</span>
+                    </Form.Item>
+
+                    {isEdit && (
+                      <Form.Item label={t('pages.clients.ipLog')}>
+                        <Button icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
+                          {clientIps.length > 0 ? clientIps.length : ''}
+                        </Button>
+                      </Form.Item>
+                    )}
+                  </>
+                ),
+              },
+              {
+                key: 'config',
+                label: t('pages.clients.tabConfig'),
+                children: (
+                  <>
+                    <Row gutter={16}>
+                      <Col xs={24} md={12}>
+                        <Form.Item label={t('pages.clients.uuid')}>
+                          <Space.Compact style={{ display: 'flex' }}>
+                            <Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
+                            <Button icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
+                          </Space.Compact>
+                        </Form.Item>
+                      </Col>
+                      <Col xs={24} md={12}>
+                        <Form.Item label={t('pages.clients.password')}>
+                          <Space.Compact style={{ display: 'flex' }}>
+                            <Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
+                            <Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
+                          </Space.Compact>
+                        </Form.Item>
+                      </Col>
+                    </Row>
+
+                    <Row gutter={16}>
+                      <Col xs={24} md={12}>
+                        <Form.Item label={t('pages.clients.subId')}>
+                          <Space.Compact style={{ display: 'flex' }}>
+                            <Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
+                            <Button icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
+                          </Space.Compact>
+                        </Form.Item>
+                      </Col>
+                      <Col xs={24} md={12}>
+                        <Form.Item label={t('pages.clients.hysteriaAuth')}>
+                          <Space.Compact style={{ display: 'flex' }}>
+                            <Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
+                            <Button icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
+                          </Space.Compact>
+                        </Form.Item>
+                      </Col>
+                    </Row>
+
+                    <Row gutter={16}>
+                      {showFlow && (
+                        <Col xs={24} md={12}>
+                          <Form.Item label={t('pages.clients.flow')}>
+                            <Select
+                              value={form.flow}
+                              onChange={(v) => update('flow', v)}
+                              options={[
+                                { value: '', label: t('none') },
+                                ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
+                              ]}
+                            />
+                          </Form.Item>
+                        </Col>
+                      )}
+                      {showSecurity && (
+                        <Col xs={24} md={12}>
+                          <Form.Item label={t('pages.clients.vmessSecurity')}>
+                            <Select
+                              value={form.security}
+                              onChange={(v) => update('security', v)}
+                              options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
+                            />
+                          </Form.Item>
+                        </Col>
+                      )}
+                      {showReverseTag && (
+                        <Col xs={24} md={12}>
+                          <Form.Item label={t('pages.clients.reverseTag')}>
+                            <Input value={form.reverseTag} placeholder={t('pages.clients.reverseTagPlaceholder')}
+                              onChange={(e) => update('reverseTag', e.target.value)} />
+                          </Form.Item>
+                        </Col>
+                      )}
+                    </Row>
+                  </>
+                ),
+              },
+            ]}
+          />
         </Form>
       </Modal>
 

+ 1 - 3
frontend/src/pages/clients/ClientsPage.tsx

@@ -196,7 +196,7 @@ export default function ClientsPage() {
     allGroups,
     setQuery,
     inbounds, onlines, loading, fetched, fetchError, subSettings,
-    ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
+    tgBotEnable, expireDiff, trafficDiff, pageSize,
     create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, bulkAttach, detach, bulkDetach,
     resetTraffic, resetAllTraffics, delDepleted, setEnable,
     applyTrafficEvent, applyClientStatsEvent,
@@ -1219,7 +1219,6 @@ export default function ClientsPage() {
             client={editingClient}
             attachedIds={editingAttachedIds}
             inbounds={inbounds}
-            ipLimitEnable={ipLimitEnable}
             tgBotEnable={tgBotEnable}
             groups={allGroups}
             save={onSave}
@@ -1248,7 +1247,6 @@ export default function ClientsPage() {
           <ClientBulkAddModal
             open={bulkAddOpen}
             inbounds={inbounds}
-            ipLimitEnable={ipLimitEnable}
             groups={allGroups}
             onOpenChange={setBulkAddOpen}
             onSaved={() => setBulkAddOpen(false)}

+ 4 - 4
frontend/src/pages/index/IndexPage.tsx

@@ -64,7 +64,7 @@ export default function IndexPage() {
   const [messageApi, messageContextHolder] = message.useMessage();
   useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
 
-  const [ipLimitEnable, setIpLimitEnable] = useState(false);
+  const [accessLogEnable, setAccessLogEnable] = useState(false);
   const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
     currentVersion: '',
     latestVersion: '',
@@ -87,8 +87,8 @@ export default function IndexPage() {
   const [loadingTip, setLoadingTip] = useState(t('loading'));
 
   useEffect(() => {
-    HttpUtil.post<{ ipLimitEnable?: boolean }>('/panel/api/setting/defaultSettings').then((msg) => {
-      if (msg?.success && msg.obj) setIpLimitEnable(!!msg.obj.ipLimitEnable);
+    HttpUtil.post<{ accessLogEnable?: boolean }>('/panel/api/setting/defaultSettings').then((msg) => {
+      if (msg?.success && msg.obj) setAccessLogEnable(!!msg.obj.accessLogEnable);
     });
     HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
       if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
@@ -186,7 +186,7 @@ export default function IndexPage() {
                     <XrayStatusCard
                       status={status}
                       isMobile={isMobile}
-                      ipLimitEnable={ipLimitEnable}
+                      accessLogEnable={accessLogEnable}
                       onStopXray={stopXray}
                       onRestartXray={restartXray}
                       onOpenXrayLogs={() => setXrayLogsOpen(true)}

+ 5 - 3
frontend/src/pages/index/XrayStatusCard.tsx

@@ -14,7 +14,7 @@ import './XrayStatusCard.css';
 interface XrayStatusCardProps {
   status: Status;
   isMobile: boolean;
-  ipLimitEnable: boolean;
+  accessLogEnable: boolean;
   onStopXray: () => void;
   onRestartXray: () => void;
   onOpenLogs: () => void;
@@ -31,7 +31,7 @@ const XRAY_STATE_KEYS: Record<string, string> = {
 export default function XrayStatusCard({
   status,
   isMobile,
-  ipLimitEnable,
+  accessLogEnable,
   onStopXray,
   onRestartXray,
   onOpenLogs,
@@ -86,7 +86,9 @@ export default function XrayStatusCard({
     );
 
   const actions = [
-    ...(ipLimitEnable
+    // the xray log viewer reads the access log file, so the button only makes
+    // sense when one is configured (unlike IP limit, which no longer needs it)
+    ...(accessLogEnable
       ? [
           <Space className="action" key="xraylogs" onClick={onOpenXrayLogs}>
             <BarsOutlined />

+ 1 - 0
frontend/src/schemas/defaults.ts

@@ -15,6 +15,7 @@ export const DefaultsPayloadSchema = z.object({
   remarkModel: z.string().optional(),
   datepicker: z.enum(['gregorian', 'jalalian']).optional(),
   ipLimitEnable: z.boolean().optional(),
+  accessLogEnable: z.boolean().optional(),
   webDomain: z.string().optional(),
   subDomain: z.string().optional(),
 }).loose();

+ 101 - 27
internal/web/job/check_client_ip_job.go

@@ -16,6 +16,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
 	"gorm.io/gorm"
@@ -27,10 +28,14 @@ type IPWithTimestamp struct {
 	Timestamp int64  `json:"timestamp"`
 }
 
-// CheckClientIpJob monitors client IP addresses from access logs and manages IP blocking based on configured limits.
+// CheckClientIpJob monitors client IP addresses and manages IP blocking based
+// on configured limits. The per-client IPs come from the core's online-stats
+// API when the running core supports it (no access log needed), falling back
+// to access-log parsing on older cores.
 type CheckClientIpJob struct {
 	lastClear     int64
 	disAllowedIps []string
+	xrayService   service.XrayService
 }
 
 var job *CheckClientIpJob
@@ -50,22 +55,32 @@ func (j *CheckClientIpJob) Run() {
 		j.lastClear = time.Now().Unix()
 	}
 
-	shouldClearAccessLog := false
 	fail2BanEnabled := isFail2BanEnabled()
 	hasLimit := fail2BanEnabled && j.hasLimitIp()
 	f2bInstalled := false
 	if hasLimit {
 		f2bInstalled = j.checkFail2BanInstalled()
 	}
+
+	if observed, apiMode := j.collectFromOnlineAPI(); apiMode {
+		if fail2BanEnabled {
+			j.processObserved(observed, j.resolveEnforce(hasLimit, f2bInstalled), true)
+		}
+		// The core tracks online IPs itself, so no access log is needed in this
+		// mode; still rotate a user-configured access log hourly so it doesn't
+		// grow unboundedly. The enforcement-triggered rotation is skipped —
+		// nothing here reads the log.
+		if j.checkAccessLogAvailable(false) && time.Now().Unix()-j.lastClear > 3600 {
+			j.clearAccessLog()
+		}
+		return
+	}
+
+	shouldClearAccessLog := false
 	isAccessLogAvailable := j.checkAccessLogAvailable(hasLimit)
 
 	if fail2BanEnabled && isAccessLogAvailable {
-		enforce := hasLimit
-		if hasLimit && runtime.GOOS != "windows" && !f2bInstalled {
-			logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
-			enforce = false
-		}
-		shouldClearAccessLog = j.processLogFile(enforce)
+		shouldClearAccessLog = j.processLogFile(j.resolveEnforce(hasLimit, f2bInstalled))
 	}
 
 	if shouldClearAccessLog || (isAccessLogAvailable && time.Now().Unix()-j.lastClear > 3600) {
@@ -73,6 +88,50 @@ func (j *CheckClientIpJob) Run() {
 	}
 }
 
+// resolveEnforce decides whether limits can actually be enforced this run,
+// warning when fail2ban is missing on a platform that needs it.
+func (j *CheckClientIpJob) resolveEnforce(hasLimit, f2bInstalled bool) bool {
+	if hasLimit && runtime.GOOS != "windows" && !f2bInstalled {
+		logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
+		return false
+	}
+	return hasLimit
+}
+
+// collectFromOnlineAPI builds per-email IP observations (email -> ip ->
+// last-seen unix seconds) from the core's online-stats API. ok=false means the
+// API is unavailable — xray not running, an older core, or a transient gRPC
+// failure — and the caller must fall back to access-log parsing.
+func (j *CheckClientIpJob) collectFromOnlineAPI() (map[string]map[string]int64, bool) {
+	onlineUsers, ok, err := j.xrayService.GetOnlineUsers()
+	if err != nil {
+		logger.Debug("[LimitIP] online-stats API unavailable this run:", err)
+		return nil, false
+	}
+	if !ok {
+		return nil, false
+	}
+	now := time.Now().Unix()
+	observed := make(map[string]map[string]int64, len(onlineUsers))
+	for _, user := range onlineUsers {
+		for _, entry := range user.IPs {
+			// No localhost guard needed here: the core's OnlineMap.AddIP drops
+			// 127.0.0.1/[::1] itself, so they never reach this list.
+			ts := entry.LastSeen
+			if ts <= 0 {
+				ts = now
+			}
+			if _, exists := observed[user.Email]; !exists {
+				observed[user.Email] = make(map[string]int64)
+			}
+			if existing, seen := observed[user.Email][entry.IP]; !seen || ts > existing {
+				observed[user.Email][entry.IP] = ts
+			}
+		}
+	}
+	return observed, true
+}
+
 func (j *CheckClientIpJob) clearAccessLog() {
 	logAccessP, err := os.OpenFile(xray.GetAccessPersistentLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
 	j.checkError(err)
@@ -183,18 +242,26 @@ func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
 		j.checkError(err)
 	}
 
+	return j.processObserved(inboundClientIps, enforce, false)
+}
+
+// processObserved runs collection + enforcement for one scan's observations
+// (email -> ip -> last-seen unix seconds). observedAreLive marks the
+// observations as live connections (online-stats API) rather than recent log
+// lines: live entries bypass the stale cutoff, since a connection that opened
+// hours ago is still live even though its timestamp is old.
+func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64, enforce, observedAreLive bool) bool {
 	shouldCleanLog := false
-	for email, ipTimestamps := range inboundClientIps {
+	for email, ipTimestamps := range observed {
 
-		// The access log can still reference a client that was just renamed
+		// The observations can still reference a client that was just renamed
 		// or deleted; its email no longer matches any inbound. Skip it (and
 		// drop any orphaned tracking row) instead of recreating a row and
-		// logging an ERROR every run until the log rotates out the old email
-		// (#4963).
+		// logging an ERROR every run (#4963).
 		inbound, err := j.getInboundByEmail(email)
 		if err != nil {
 			if errors.Is(err, gorm.ErrRecordNotFound) {
-				logger.Debugf("[LimitIP] skipping stale access-log email %q (renamed or deleted)", email)
+				logger.Debugf("[LimitIP] skipping stale observed email %q (renamed or deleted)", email)
 				j.delInboundClientIps(email)
 			} else {
 				j.checkError(err)
@@ -214,13 +281,17 @@ func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
 			continue
 		}
 
-		shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, inbound, email, ipsWithTime, enforce) || shouldCleanLog
+		shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, inbound, email, ipsWithTime, enforce, observedAreLive) || shouldCleanLog
 	}
 
 	return shouldCleanLog
 }
 
-func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]int64 {
+// mergeClientIps folds this scan's observations into the persisted set,
+// dropping entries older than staleCutoff. newAlwaysLive exempts the new
+// entries from that cutoff: an API-observed IP is a live connection by
+// definition, even when its lastSeen (set at dispatch time) is hours old.
+func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64, newAlwaysLive bool) map[string]int64 {
 	ipMap := make(map[string]int64, len(old)+len(new))
 	for _, ipTime := range old {
 		if ipTime.Timestamp < staleCutoff {
@@ -229,7 +300,7 @@ func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]in
 		ipMap[ipTime.IP] = ipTime.Timestamp
 	}
 	for _, ipTime := range new {
-		if ipTime.Timestamp < staleCutoff {
+		if !newAlwaysLive && ipTime.Timestamp < staleCutoff {
 			continue
 		}
 		if existingTime, ok := ipMap[ipTime.IP]; !ok || ipTime.Timestamp > existingTime {
@@ -239,6 +310,16 @@ func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]in
 	return ipMap
 }
 
+// selectIpsToBan splits the live IPs (sorted oldest-first by partitionLiveIps)
+// into the newest `limit` entries to keep and the older remainder to ban.
+func selectIpsToBan(live []IPWithTimestamp, limit int) (kept, banned []IPWithTimestamp) {
+	if limit <= 0 || len(live) <= limit {
+		return live, nil
+	}
+	cutoff := len(live) - limit
+	return live[cutoff:], live[:cutoff]
+}
+
 func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
 	live = make([]IPWithTimestamp, 0, len(observedThisScan))
 	historical = make([]IPWithTimestamp, 0, len(ipMap))
@@ -343,7 +424,7 @@ func (j *CheckClientIpJob) delInboundClientIps(clientEmail string) {
 	}
 }
 
-func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce bool) bool {
+func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce, observedAreLive bool) bool {
 	if inbound.Settings == "" {
 		logger.Debug("wrong data:", inbound)
 		return false
@@ -380,7 +461,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
 		json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
 	}
 
-	ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, time.Now().Unix()-ipStaleAfterSeconds)
+	ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, time.Now().Unix()-ipStaleAfterSeconds, observedAreLive)
 
 	// only ips seen in this scan count toward the limit. see
 	// partitionLiveIps.
@@ -394,15 +475,10 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
 	j.disAllowedIps = []string{}
 
 	// historical db-only ips are excluded from this count on purpose.
-	var keptLive []IPWithTimestamp
-	if len(liveIps) > limitIp {
+	keptLive, bannedLive := selectIpsToBan(liveIps, limitIp)
+	if len(bannedLive) > 0 {
 		shouldCleanLog = true
 
-		// keep the newest live ips, ban older ones.
-		cutoff := len(liveIps) - limitIp
-		keptLive = liveIps[cutoff:]
-		bannedLive := liveIps[:cutoff]
-
 		logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
 		if err != nil {
 			logger.Errorf("failed to open IP limit log file: %s", err)
@@ -422,8 +498,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
 
 		// force xray to drop existing connections from banned ips
 		j.disconnectClientTemporarily(inbound, clientEmail, clients)
-	} else {
-		keptLive = liveIps
 	}
 
 	// keep kept-live + historical in the blob so the panel keeps showing

+ 2 - 2
internal/web/job/check_client_ip_job_integration_test.go

@@ -199,7 +199,7 @@ func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testin
 	if err != nil {
 		t.Fatalf("getInboundByEmail: %v", err)
 	}
-	shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true)
+	shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true, false)
 
 	if shouldCleanLog {
 		t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
@@ -252,7 +252,7 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
 	if err != nil {
 		t.Fatalf("getInboundByEmail: %v", err)
 	}
-	shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true)
+	shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true, false)
 
 	if !shouldCleanLog {
 		t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")

+ 65 - 5
internal/web/job/check_client_ip_job_test.go

@@ -22,7 +22,7 @@ func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) {
 		{IP: "2.2.2.2", Timestamp: 2000}, // same IP, newer log line
 	}
 
-	got := mergeClientIps(old, new, 1000)
+	got := mergeClientIps(old, new, 1000, false)
 
 	want := map[string]int64{"2.2.2.2": 2000}
 	if !reflect.DeepEqual(got, want) {
@@ -36,7 +36,7 @@ func TestMergeClientIps_KeepsFreshOldEntriesUnchanged(t *testing.T) {
 	old := []IPWithTimestamp{
 		{IP: "1.1.1.1", Timestamp: 1500},
 	}
-	got := mergeClientIps(old, nil, 1000)
+	got := mergeClientIps(old, nil, 1000, false)
 
 	want := map[string]int64{"1.1.1.1": 1500}
 	if !reflect.DeepEqual(got, want) {
@@ -48,7 +48,7 @@ func TestMergeClientIps_PrefersLaterTimestampForSameIp(t *testing.T) {
 	old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1500}}
 	new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1700}}
 
-	got := mergeClientIps(old, new, 1000)
+	got := mergeClientIps(old, new, 1000, false)
 
 	if got["1.1.1.1"] != 1700 {
 		t.Fatalf("expected latest timestamp 1700, got %d", got["1.1.1.1"])
@@ -59,7 +59,7 @@ func TestMergeClientIps_DropsStaleNewEntries(t *testing.T) {
 	// A log line with a clock-skewed old timestamp must not resurrect a
 	// stale IP past the cutoff.
 	new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 500}}
-	got := mergeClientIps(nil, new, 1000)
+	got := mergeClientIps(nil, new, 1000, false)
 
 	if len(got) != 0 {
 		t.Fatalf("stale new IP should have been dropped, got %v", got)
@@ -72,7 +72,7 @@ func TestMergeClientIps_NoStaleCutoffStillWorks(t *testing.T) {
 	old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 100}}
 	new := []IPWithTimestamp{{IP: "2.2.2.2", Timestamp: 200}}
 
-	got := mergeClientIps(old, new, 0)
+	got := mergeClientIps(old, new, 0, false)
 
 	want := map[string]int64{"1.1.1.1": 100, "2.2.2.2": 200}
 	if !reflect.DeepEqual(got, want) {
@@ -80,6 +80,66 @@ func TestMergeClientIps_NoStaleCutoffStillWorks(t *testing.T) {
 	}
 }
 
+func TestMergeClientIps_LiveObservationsBypassStaleCutoff(t *testing.T) {
+	// online-API mode: lastSeen is set when the connection was dispatched, so
+	// a connection held open for hours has an "old" timestamp while being live
+	// by definition. It must survive the stale cutoff.
+	new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 500}} // opened long ago, still connected
+	got := mergeClientIps(nil, new, 1000, true)
+
+	want := map[string]int64{"1.1.1.1": 500}
+	if !reflect.DeepEqual(got, want) {
+		t.Fatalf("live observation must bypass the stale cutoff\ngot:  %v\nwant: %v", got, want)
+	}
+}
+
+func TestMergeClientIps_LiveModeStillEvictsStaleOldEntries(t *testing.T) {
+	// the bypass applies only to this scan's observations — persisted entries
+	// from past scans still age out as before.
+	old := []IPWithTimestamp{{IP: "2.2.2.2", Timestamp: 100}}
+	new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 2000}}
+	got := mergeClientIps(old, new, 1000, true)
+
+	want := map[string]int64{"1.1.1.1": 2000}
+	if !reflect.DeepEqual(got, want) {
+		t.Fatalf("stale db entry must still be evicted in live mode\ngot:  %v\nwant: %v", got, want)
+	}
+}
+
+func TestSelectIpsToBan(t *testing.T) {
+	live := []IPWithTimestamp{ // sorted oldest-first, as partitionLiveIps returns
+		{IP: "A", Timestamp: 100},
+		{IP: "B", Timestamp: 200},
+		{IP: "C", Timestamp: 300},
+	}
+
+	// over the limit: oldest connections are banned, newest keep the slots
+	kept, banned := selectIpsToBan(live, 1)
+	if got := collectIps(kept); !reflect.DeepEqual(got, []string{"C"}) {
+		t.Fatalf("newest ip must keep the slot, got %v", got)
+	}
+	if got := collectIps(banned); !reflect.DeepEqual(got, []string{"A", "B"}) {
+		t.Fatalf("older ips must be banned oldest-first, got %v", got)
+	}
+
+	// at the limit: nothing banned
+	kept, banned = selectIpsToBan(live, 3)
+	if len(banned) != 0 || len(kept) != 3 {
+		t.Fatalf("at-limit set must not ban, kept=%v banned=%v", kept, banned)
+	}
+
+	// under the limit: nothing banned
+	kept, banned = selectIpsToBan(live[:1], 3)
+	if len(banned) != 0 || len(kept) != 1 {
+		t.Fatalf("under-limit set must not ban, kept=%v banned=%v", kept, banned)
+	}
+
+	// defensive: non-positive limit never reaches enforcement, but must not panic
+	if _, banned := selectIpsToBan(live, 0); banned != nil {
+		t.Fatalf("zero limit must not ban, got %v", banned)
+	}
+}
+
 func collectIps(entries []IPWithTimestamp) []string {
 	out := make([]string, 0, len(entries))
 	for _, e := range entries {

+ 30 - 7
internal/web/job/xray_traffic_job.go

@@ -66,21 +66,37 @@ func (j *XrayTrafficJob) Run() {
 		j.xrayService.SetToNeedRestart()
 	}
 
-	lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
-	if err != nil {
-		logger.Warning("get clients last online failed:", err)
-	}
-	if lastOnlineMap == nil {
-		lastOnlineMap = make(map[string]int64)
-	}
 	// Derive the local online set from this poll's per-email deltas rather
 	// than the shared last_online column, which remote-node syncs also bump
 	// and would otherwise make a client active only on a remote node appear
 	// online on local inbounds.
 	activeEmails := make([]string, 0, len(clientTraffics))
+	deltaActive := make(map[string]bool, len(clientTraffics))
 	for _, ct := range clientTraffics {
 		if ct != nil && ct.Up+ct.Down > 0 {
 			activeEmails = append(activeEmails, ct.Email)
+			deltaActive[ct.Email] = true
+		}
+	}
+	// When the core supports the online-stats API, union in connection-based
+	// onlines. Neither signal alone covers everything: an idle-but-connected
+	// client moves no bytes between polls (the delta heuristic's blind spot),
+	// while a short-lived connection can close before this poll yet still show
+	// in the delta. Older cores fall back to deltas alone.
+	if onlineUsers, apiMode, ouErr := j.xrayService.GetOnlineUsers(); ouErr != nil {
+		logger.Debug("get online users from xray api failed:", ouErr)
+	} else if apiMode {
+		idleOnline := make([]string, 0, len(onlineUsers))
+		for _, u := range onlineUsers {
+			if !deltaActive[u.Email] {
+				activeEmails = append(activeEmails, u.Email)
+				idleOnline = append(idleOnline, u.Email)
+			}
+		}
+		// The traffic path only bumps last_online on a non-zero delta; keep the
+		// column fresh for clients kept online purely by a live connection.
+		if err := j.inboundService.BumpClientsLastOnline(idleOnline); err != nil {
+			logger.Warning("bump last online for connected clients failed:", err)
 		}
 	}
 	// Pair the email signal with the inbound tags that moved bytes this poll.
@@ -100,6 +116,13 @@ func (j *XrayTrafficJob) Run() {
 		return
 	}
 
+	lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
+	if err != nil {
+		logger.Warning("get clients last online failed:", err)
+	}
+	if lastOnlineMap == nil {
+		lastOnlineMap = make(map[string]int64)
+	}
 	onlineClients := j.inboundService.GetOnlineClients()
 	if onlineClients == nil {
 		onlineClients = []string{}

+ 22 - 0
internal/web/service/inbound_traffic.go

@@ -837,6 +837,28 @@ func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffi
 	return traffics, nil
 }
 
+// BumpClientsLastOnline sets client_traffics.last_online to now for the given
+// emails. Used in online-API mode for clients that hold a live connection but
+// moved no bytes this poll — the traffic path (addClientTraffic) only bumps
+// last_online on a non-zero delta, so idle-but-connected clients would
+// otherwise show a stale "last online" while being reported online.
+func (s *InboundService) BumpClientsLastOnline(emails []string) error {
+	uniq := uniqueNonEmptyStrings(emails)
+	if len(uniq) == 0 {
+		return nil
+	}
+	now := time.Now().UnixMilli()
+	return submitTrafficWrite(func() error {
+		db := database.GetDB()
+		for _, batch := range chunkStrings(uniq, sqliteMaxVars) {
+			if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Update("last_online", now).Error; err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+}
+
 func (s *InboundService) GetActiveClientTraffics(emails []string) ([]*xray.ClientTraffic, error) {
 	uniq := uniqueNonEmptyStrings(emails)
 	if len(uniq) == 0 {

+ 31 - 19
internal/web/service/setting.go

@@ -798,7 +798,18 @@ func (s *SettingService) SetRestartXrayOnClientDisable(value bool) error {
 	return s.setBool("restartXrayOnClientDisable", value)
 }
 
+// GetIpLimitEnable reports whether the IP-limit feature is available. Always
+// true since the panel enforces limits via the core's online-stats API; on an
+// older core the job falls back to access-log parsing and warns there when the
+// log is missing, so the UI no longer hides the field behind that condition.
 func (s *SettingService) GetIpLimitEnable() (bool, error) {
+	return true, nil
+}
+
+// GetAccessLogEnable reports whether an Xray access log is configured. Used by
+// the UI for features that genuinely read the log file (the xray log viewer) —
+// distinct from IP limiting, which works without it.
+func (s *SettingService) GetAccessLogEnable() (bool, error) {
 	accessLogPath, err := xray.GetAccessLogPath()
 	if err != nil {
 		return false, err
@@ -1022,25 +1033,26 @@ func (s *SettingService) BuildSubURIBase(host string) string {
 func (s *SettingService) GetDefaultSettings(host string) (any, error) {
 	type settingFunc func() (any, error)
 	settings := map[string]settingFunc{
-		"expireDiff":     func() (any, error) { return s.GetExpireDiff() },
-		"trafficDiff":    func() (any, error) { return s.GetTrafficDiff() },
-		"pageSize":       func() (any, error) { return s.GetPageSize() },
-		"defaultCert":    func() (any, error) { return s.GetCertFile() },
-		"defaultKey":     func() (any, error) { return s.GetKeyFile() },
-		"tgBotEnable":    func() (any, error) { return s.GetTgbotEnabled() },
-		"subThemeDir":    func() (any, error) { return s.GetSubThemeDir() },
-		"subEnable":      func() (any, error) { return s.GetSubEnable() },
-		"subJsonEnable":  func() (any, error) { return s.GetSubJsonEnable() },
-		"subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
-		"subTitle":       func() (any, error) { return s.GetSubTitle() },
-		"subURI":         func() (any, error) { return s.GetSubURI() },
-		"subJsonURI":     func() (any, error) { return s.GetSubJsonURI() },
-		"subClashURI":    func() (any, error) { return s.GetSubClashURI() },
-		"remarkModel":    func() (any, error) { return s.GetRemarkModel() },
-		"datepicker":     func() (any, error) { return s.GetDatepicker() },
-		"ipLimitEnable":  func() (any, error) { return s.GetIpLimitEnable() },
-		"webDomain":      func() (any, error) { return s.GetWebDomain() },
-		"subDomain":      func() (any, error) { return s.GetSubDomain() },
+		"expireDiff":      func() (any, error) { return s.GetExpireDiff() },
+		"trafficDiff":     func() (any, error) { return s.GetTrafficDiff() },
+		"pageSize":        func() (any, error) { return s.GetPageSize() },
+		"defaultCert":     func() (any, error) { return s.GetCertFile() },
+		"defaultKey":      func() (any, error) { return s.GetKeyFile() },
+		"tgBotEnable":     func() (any, error) { return s.GetTgbotEnabled() },
+		"subThemeDir":     func() (any, error) { return s.GetSubThemeDir() },
+		"subEnable":       func() (any, error) { return s.GetSubEnable() },
+		"subJsonEnable":   func() (any, error) { return s.GetSubJsonEnable() },
+		"subClashEnable":  func() (any, error) { return s.GetSubClashEnable() },
+		"subTitle":        func() (any, error) { return s.GetSubTitle() },
+		"subURI":          func() (any, error) { return s.GetSubURI() },
+		"subJsonURI":      func() (any, error) { return s.GetSubJsonURI() },
+		"subClashURI":     func() (any, error) { return s.GetSubClashURI() },
+		"remarkModel":     func() (any, error) { return s.GetRemarkModel() },
+		"datepicker":      func() (any, error) { return s.GetDatepicker() },
+		"ipLimitEnable":   func() (any, error) { return s.GetIpLimitEnable() },
+		"accessLogEnable": func() (any, error) { return s.GetAccessLogEnable() },
+		"webDomain":       func() (any, error) { return s.GetWebDomain() },
+		"subDomain":       func() (any, error) { return s.GetSubDomain() },
 	}
 
 	result := make(map[string]any)

+ 83 - 0
internal/web/service/xray.go

@@ -116,6 +116,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 	}
 	xrayConfig.LogConfig = resolveXrayLogPaths(xrayConfig.LogConfig)
 	xrayConfig.API = ensureAPIServices(xrayConfig.API)
+	xrayConfig.Policy = ensureStatsPolicy(xrayConfig.Policy)
 
 	_, _, _ = s.inboundService.AddTraffic(nil, nil)
 
@@ -421,6 +422,51 @@ func ensureAPIServices(api json_util.RawMessage) json_util.RawMessage {
 	return out
 }
 
+// ensureStatsPolicy guarantees every policy level in the generated config has
+// statsUserOnline enabled, so the core tracks per-email online IPs for the
+// panel's online view and access-log-free IP limiting. Generated clients carry
+// no explicit level, so level "0" is created when absent. The flag is panel
+// infrastructure and is forced on even over an explicit false in the template,
+// same as the api services above. An entirely missing or unparsable policy
+// block is left alone; the stored template itself is never modified — only the
+// generated runtime config.
+func ensureStatsPolicy(policy json_util.RawMessage) json_util.RawMessage {
+	if len(policy) == 0 {
+		return policy
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal(policy, &parsed); err != nil {
+		return policy
+	}
+	levels, _ := parsed["levels"].(map[string]any)
+	if levels == nil {
+		levels = make(map[string]any)
+	}
+	if _, ok := levels["0"]; !ok {
+		levels["0"] = map[string]any{}
+	}
+	changed := false
+	for _, raw := range levels {
+		level, ok := raw.(map[string]any)
+		if !ok {
+			continue
+		}
+		if enabled, ok := level["statsUserOnline"].(bool); !ok || !enabled {
+			level["statsUserOnline"] = true
+			changed = true
+		}
+	}
+	if !changed {
+		return policy
+	}
+	parsed["levels"] = levels
+	out, err := json.Marshal(parsed)
+	if err != nil {
+		return policy
+	}
+	return out
+}
+
 // resolveXrayLogPaths rewrites relative `log.access` / `log.error` values to
 // absolute paths under config.GetLogFolder(), so Xray writes those files
 // alongside the panel's other logs regardless of the working directory the
@@ -493,6 +539,43 @@ func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic,
 	return traffic, clientTraffic, nil
 }
 
+// GetOnlineUsers returns connection-based online users (email + source IPs)
+// from the running core's online-stats API. ok=false means the API is not
+// available — xray isn't running or the core predates the online-stats RPCs —
+// and callers must use the legacy traffic-delta / access-log paths. The
+// capability is probed lazily per process: an Unimplemented answer pins this
+// core as unsupported until the next restart, while transient errors leave the
+// capability undecided so a flaky poll can't lock in legacy mode.
+func (s *XrayService) GetOnlineUsers() ([]xray.OnlineUser, bool, error) {
+	if !s.IsXrayRunning() {
+		return nil, false, nil
+	}
+	if p.OnlineAPISupport() == xray.OnlineAPIUnsupported {
+		return nil, false, nil
+	}
+	if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
+		logger.Debug("Failed to initialize Xray API:", err)
+		return nil, false, err
+	}
+	defer s.xrayAPI.Close()
+
+	users, err := s.xrayAPI.GetOnlineUsers()
+	if err != nil {
+		if xray.IsUnimplementedErr(err) {
+			p.SetOnlineAPISupport(xray.OnlineAPIUnsupported)
+			logger.Info("xray core does not support the online-stats API; falling back to traffic-delta onlines and access-log IP limit")
+			return nil, false, nil
+		}
+		logger.Debug("Failed to fetch Xray online users:", err)
+		return nil, false, err
+	}
+	if p.OnlineAPISupport() == xray.OnlineAPIUnknown {
+		p.SetOnlineAPISupport(xray.OnlineAPISupported)
+		logger.Info("xray core supports the online-stats API; using connection-based onlines and access-log-free IP limit")
+	}
+	return users, true, nil
+}
+
 // BalancerStatus is the live view of one balancer for the panel UI. Running
 // is false when the balancer isn't present in the running core (e.g. xray is
 // stopped or the balancer hasn't been saved/applied yet).

+ 65 - 0
internal/web/service/xray_config_inject_test.go

@@ -54,6 +54,71 @@ func TestEnsureAPIServices(t *testing.T) {
 	}
 }
 
+func TestEnsureStatsPolicy(t *testing.T) {
+	// default-template shape: level "0" exists with traffic flags — the online
+	// flag is added and the siblings survive untouched
+	out := ensureStatsPolicy(json_util.RawMessage(`{"levels":{"0":{"handshake":4,"statsUserUplink":true,"statsUserDownlink":true}},"system":{"statsInboundDownlink":true}}`))
+	var parsed struct {
+		Levels map[string]map[string]any `json:"levels"`
+		System map[string]any            `json:"system"`
+	}
+	if err := json.Unmarshal(out, &parsed); err != nil {
+		t.Fatal(err)
+	}
+	level0 := parsed.Levels["0"]
+	if level0["statsUserOnline"] != true {
+		t.Fatalf("statsUserOnline must be injected into level 0, got %v", level0)
+	}
+	if level0["statsUserUplink"] != true || level0["statsUserDownlink"] != true || level0["handshake"] != float64(4) {
+		t.Fatalf("sibling keys must be preserved, got %v", level0)
+	}
+	if parsed.System["statsInboundDownlink"] != true {
+		t.Fatalf("system block must be preserved, got %v", parsed.System)
+	}
+
+	// missing levels block: level "0" is created with the flag
+	out = ensureStatsPolicy(json_util.RawMessage(`{"system":{}}`))
+	if err := json.Unmarshal(out, &parsed); err != nil {
+		t.Fatal(err)
+	}
+	if parsed.Levels["0"]["statsUserOnline"] != true {
+		t.Fatalf("level 0 must be created with statsUserOnline, got %s", out)
+	}
+
+	// every level gets the flag, an explicit false included — the flag is
+	// panel infrastructure, like the api services
+	out = ensureStatsPolicy(json_util.RawMessage(`{"levels":{"0":{"statsUserOnline":false},"1":{"connIdle":300}}}`))
+	if err := json.Unmarshal(out, &parsed); err != nil {
+		t.Fatal(err)
+	}
+	for _, key := range []string{"0", "1"} {
+		if parsed.Levels[key]["statsUserOnline"] != true {
+			t.Fatalf("level %s must have statsUserOnline forced on, got %s", key, out)
+		}
+	}
+	if parsed.Levels["1"]["connIdle"] != float64(300) {
+		t.Fatalf("level 1 siblings must be preserved, got %s", out)
+	}
+
+	// already-enabled input passes through byte-identical (no marshal churn,
+	// no spurious restart)
+	full := json_util.RawMessage(`{"levels":{"0":{"statsUserOnline":true}}}`)
+	if got := ensureStatsPolicy(full); string(got) != string(full) {
+		t.Fatalf("already-enabled policy must pass through untouched, got %s", got)
+	}
+
+	// absent policy block stays absent
+	if got := ensureStatsPolicy(nil); got != nil {
+		t.Fatalf("nil policy must stay nil, got %s", got)
+	}
+
+	// unparsable policy is left untouched
+	bad := json_util.RawMessage(`{not json`)
+	if got := ensureStatsPolicy(bad); string(got) != string(bad) {
+		t.Fatalf("unparsable policy must be left untouched, got %s", got)
+	}
+}
+
 func egressTestConfig() *xray.Config {
 	return &xray.Config{
 		RouterConfig: json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"}]}`),

+ 2 - 0
internal/web/translation/ar-EG.json

@@ -627,6 +627,8 @@
       }
     },
     "clients": {
+      "tabBasic": "أساسي",
+      "tabConfig": "التكوين",
       "add": "إضافة عميل",
       "edit": "تعديل العميل",
       "submitAdd": "إضافة عميل",

+ 2 - 0
internal/web/translation/en-US.json

@@ -628,6 +628,8 @@
       }
     },
     "clients": {
+      "tabBasic": "Basic",
+      "tabConfig": "Config",
       "add": "Add Client",
       "edit": "Edit Client",
       "submitAdd": "Add Client",

+ 2 - 0
internal/web/translation/es-ES.json

@@ -627,6 +627,8 @@
       }
     },
     "clients": {
+      "tabBasic": "Básico",
+      "tabConfig": "Configuración",
       "add": "Añadir cliente",
       "edit": "Editar cliente",
       "submitAdd": "Añadir cliente",

+ 2 - 0
internal/web/translation/fa-IR.json

@@ -627,6 +627,8 @@
       }
     },
     "clients": {
+      "tabBasic": "پایه",
+      "tabConfig": "پیکربندی",
       "add": "افزودن کلاینت",
       "edit": "ویرایش کلاینت",
       "submitAdd": "افزودن کلاینت",

+ 2 - 0
internal/web/translation/id-ID.json

@@ -627,6 +627,8 @@
       }
     },
     "clients": {
+      "tabBasic": "Dasar",
+      "tabConfig": "Konfigurasi",
       "add": "Tambah klien",
       "edit": "Ubah klien",
       "submitAdd": "Tambah klien",

+ 2 - 0
internal/web/translation/ja-JP.json

@@ -627,6 +627,8 @@
       }
     },
     "clients": {
+      "tabBasic": "基本",
+      "tabConfig": "設定",
       "add": "クライアントを追加",
       "edit": "クライアントを編集",
       "submitAdd": "クライアントを追加",

+ 2 - 0
internal/web/translation/pt-BR.json

@@ -627,6 +627,8 @@
       }
     },
     "clients": {
+      "tabBasic": "Básico",
+      "tabConfig": "Configuração",
       "add": "Adicionar cliente",
       "edit": "Editar cliente",
       "submitAdd": "Adicionar cliente",

+ 2 - 0
internal/web/translation/ru-RU.json

@@ -627,6 +627,8 @@
       }
     },
     "clients": {
+      "tabBasic": "Основные",
+      "tabConfig": "Конфигурация",
       "add": "Добавить клиента",
       "edit": "Изменить клиента",
       "submitAdd": "Добавить клиента",

+ 2 - 0
internal/web/translation/tr-TR.json

@@ -628,6 +628,8 @@
       }
     },
     "clients": {
+      "tabBasic": "Temel",
+      "tabConfig": "Yapılandırma",
       "add": "Kullanıcı Ekle",
       "edit": "Kullanıcıyı Düzenle",
       "submitAdd": "Kullanıcı Ekle",

+ 2 - 0
internal/web/translation/uk-UA.json

@@ -627,6 +627,8 @@
       }
     },
     "clients": {
+      "tabBasic": "Основні",
+      "tabConfig": "Конфігурація",
       "add": "Додати клієнта",
       "edit": "Редагувати клієнта",
       "submitAdd": "Додати клієнта",

+ 2 - 0
internal/web/translation/vi-VN.json

@@ -627,6 +627,8 @@
       }
     },
     "clients": {
+      "tabBasic": "Cơ bản",
+      "tabConfig": "Cấu hình",
       "add": "Thêm khách hàng",
       "edit": "Chỉnh sửa khách hàng",
       "submitAdd": "Thêm khách hàng",

+ 2 - 0
internal/web/translation/zh-CN.json

@@ -627,6 +627,8 @@
       }
     },
     "clients": {
+      "tabBasic": "基本",
+      "tabConfig": "配置",
       "add": "添加客户端",
       "edit": "编辑客户端",
       "submitAdd": "添加客户端",

+ 2 - 0
internal/web/translation/zh-TW.json

@@ -627,6 +627,8 @@
       }
     },
     "clients": {
+      "tabBasic": "基本",
+      "tabConfig": "配置",
       "add": "新增客戶端",
       "edit": "編輯客戶端",
       "submitAdd": "新增客戶端",

+ 60 - 2
internal/xray/api.go

@@ -33,7 +33,9 @@ import (
 	"github.com/xtls/xray-core/proxy/vless"
 	"github.com/xtls/xray-core/proxy/vmess"
 	"google.golang.org/grpc"
+	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/credentials/insecure"
+	"google.golang.org/grpc/status"
 )
 
 // XrayAPI is a gRPC client for managing Xray core configuration, inbounds, outbounds, and statistics.
@@ -289,8 +291,8 @@ type RouteTestRequest struct {
 type RouteTestResult struct {
 	// Matched is false when no routing rule matched — traffic would use the
 	// default (first) outbound and OutboundTag is empty.
-	Matched     bool     `json:"matched"`
-	OutboundTag string   `json:"outboundTag"`
+	Matched     bool   `json:"matched"`
+	OutboundTag string `json:"outboundTag"`
 	// GroupTags lists the balancer chain the decision went through, when any.
 	GroupTags []string `json:"groupTags,omitempty"`
 }
@@ -571,6 +573,62 @@ func (x *XrayAPI) GetTraffic() ([]*Traffic, []*ClientTraffic, error) {
 	return mapToSlice(tagTrafficMap), mapToSlice(emailTrafficMap), nil
 }
 
+// OnlineIP is one source address of a live connection, with the unix time (seconds)
+// the core last dispatched a link from it.
+type OnlineIP struct {
+	IP       string `json:"ip"`
+	LastSeen int64  `json:"lastSeen"`
+}
+
+// OnlineUser is a client email with at least one live connection and the source
+// IPs of those connections, as tracked by Xray's statsUserOnline policy.
+type OnlineUser struct {
+	Email string     `json:"email"`
+	IPs   []OnlineIP `json:"ips"`
+}
+
+// GetOnlineUsers returns every user with at least one live connection plus their
+// source IPs, via StatsService.GetUsersStats (one RPC covers all users). Requires
+// statsUserOnline enabled in the policy levels; older cores return Unimplemented.
+func (x *XrayAPI) GetOnlineUsers() ([]OnlineUser, error) {
+	if x.grpcClient == nil {
+		return nil, common.NewError("xray api is not initialized")
+	}
+	if x.StatsServiceClient == nil {
+		return nil, common.NewError("xray StatsServiceClient is not initialized")
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
+	defer cancel()
+
+	resp, err := (*x.StatsServiceClient).GetUsersStats(ctx, &statsService.GetUsersStatsRequest{})
+	if err != nil {
+		return nil, err
+	}
+
+	users := make([]OnlineUser, 0, len(resp.GetUsers()))
+	for _, u := range resp.GetUsers() {
+		if u == nil || u.GetEmail() == "" {
+			continue
+		}
+		ips := make([]OnlineIP, 0, len(u.GetIps()))
+		for _, entry := range u.GetIps() {
+			if entry == nil || entry.GetIp() == "" {
+				continue
+			}
+			ips = append(ips, OnlineIP{IP: entry.GetIp(), LastSeen: entry.GetLastSeen()})
+		}
+		users = append(users, OnlineUser{Email: u.GetEmail(), IPs: ips})
+	}
+	return users, nil
+}
+
+// IsUnimplementedErr reports whether err is the running core saying it lacks an
+// RPC (an older Xray binary without the online-stats API).
+func IsUnimplementedErr(err error) bool {
+	return status.Code(err) == codes.Unimplemented
+}
+
 // processTraffic aggregates a traffic stat into trafficMap using regex matches and value.
 func processTraffic(matches []string, value int64, trafficMap map[string]*Traffic) {
 	isInbound := matches[1] == "inbound"

+ 19 - 2
internal/xray/api_e2e_test.go

@@ -53,8 +53,12 @@ func TestXrayAPI_E2E(t *testing.T) {
 				map[string]any{"type": "field", "inboundTag": []string{"api"}, "outboundTag": "api"},
 			},
 		},
-		"policy": map[string]any{},
-		"stats":  map[string]any{},
+		"policy": map[string]any{
+			"levels": map[string]any{
+				"0": map[string]any{"statsUserOnline": true},
+			},
+		},
+		"stats": map[string]any{},
 	}
 	cfgBytes, err := json.MarshalIndent(cfg, "", "  ")
 	if err != nil {
@@ -130,6 +134,19 @@ func TestXrayAPI_E2E(t *testing.T) {
 		t.Fatalf("missing inbound error not matched by IsMissingHandlerErr: %q", err)
 	}
 
+	// --- online-stats API ---
+	// statsUserOnline is enabled in the policy above; with no client
+	// connections the call must succeed and return an empty set. This proves
+	// the GetUsersStats plumbing against a real core (an older binary would
+	// return Unimplemented here — see IsUnimplementedErr).
+	online, err := api.GetOnlineUsers()
+	if err != nil {
+		t.Fatalf("GetOnlineUsers: %v", err)
+	}
+	if len(online) != 0 {
+		t.Fatalf("expected no online users on an idle core, got %+v", online)
+	}
+
 	// --- routing (rules + balancers replace) ---
 	newRouting := []byte(`{
 		"domainStrategy": "AsIs",

+ 18 - 0
internal/xray/online_test.go

@@ -129,3 +129,21 @@ func TestClearNodeOnlineClientsDropsNode(t *testing.T) {
 		t.Errorf("node 3's subtree should be absent after ClearNodeOnlineClients")
 	}
 }
+
+// TestOnlineAPISupportTriState pins the lazy capability probe contract: a new
+// process starts Unknown (so the first caller probes), and the flag holds
+// whatever the probe recorded until the process is replaced on restart.
+func TestOnlineAPISupportTriState(t *testing.T) {
+	p := newOnlineTestProcess()
+	if got := p.OnlineAPISupport(); got != OnlineAPIUnknown {
+		t.Fatalf("new process must start with OnlineAPIUnknown, got %v", got)
+	}
+	p.SetOnlineAPISupport(OnlineAPISupported)
+	if got := p.OnlineAPISupport(); got != OnlineAPISupported {
+		t.Fatalf("expected OnlineAPISupported, got %v", got)
+	}
+	p.SetOnlineAPISupport(OnlineAPIUnsupported)
+	if got := p.OnlineAPISupport(); got != OnlineAPIUnsupported {
+		t.Fatalf("expected OnlineAPIUnsupported, got %v", got)
+	}
+}

+ 29 - 0
internal/xray/process.go

@@ -172,6 +172,12 @@ type process struct {
 	nodeOnlineTrees map[int]map[string][]string
 	onlineMu        sync.RWMutex
 
+	// onlineAPISupport caches whether the running core implements the
+	// online-stats RPCs (GetUsersStats). A new process is created on every
+	// restart/version switch, so the flag resets to Unknown and is re-probed
+	// lazily by the first caller.
+	onlineAPISupport atomic.Int32
+
 	config     *Config
 	configPath string // if set, use this path instead of GetConfigPath() and remove on Stop
 	logWriter  *LogWriter
@@ -181,6 +187,29 @@ type process struct {
 	intentionalStop atomic.Bool
 }
 
+// OnlineAPISupport describes whether the running Xray core implements the
+// online-stats API (statsUserOnline + GetUsersStats).
+type OnlineAPISupport int32
+
+const (
+	// OnlineAPIUnknown means support has not been probed yet for this process.
+	OnlineAPIUnknown OnlineAPISupport = iota
+	// OnlineAPISupported means the core answered the online-stats RPC.
+	OnlineAPISupported
+	// OnlineAPIUnsupported means the core returned Unimplemented (older binary).
+	OnlineAPIUnsupported
+)
+
+// OnlineAPISupport returns the cached online-stats capability of this process.
+func (p *process) OnlineAPISupport() OnlineAPISupport {
+	return OnlineAPISupport(p.onlineAPISupport.Load())
+}
+
+// SetOnlineAPISupport records the probed online-stats capability of this process.
+func (p *process) SetOnlineAPISupport(v OnlineAPISupport) {
+	p.onlineAPISupport.Store(int32(v))
+}
+
 var (
 	xrayGracefulStopTimeout = 5 * time.Second
 	xrayForceStopTimeout    = 2 * time.Second