5 Incheckningar 950a647bcc ... 66d4d04776

Upphovsman SHA1 Meddelande Datum
  MHSanaei 66d4d04776 fix(iplimit): populate client IP log without an IP limit 12 timmar sedan
  MHSanaei 91f325eca6 feat(clients): show filtered count in clients list 12 timmar sedan
  MHSanaei 61105c2b1a feat(clients,routing): label inbounds by remark with tag fallback 13 timmar sedan
  xiaoxiyao 10c185a592 fix(sub): escape Clash subscription profile filename header (#4799) 13 timmar sedan
  MHSanaei 02043a432d fix(node): fix "invalid input" on save and gate save on connectivity 13 timmar sedan

+ 21 - 0
frontend/src/api/queries/useInboundOptions.ts

@@ -0,0 +1,21 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { HttpUtil } from '@/utils';
+import { parseMsg } from '@/utils/zodValidate';
+import { keys } from '@/api/queryKeys';
+import { InboundOptionsSchema, type InboundOption } from '@/schemas/client';
+
+async function fetchInboundOptions(): Promise<InboundOption[]> {
+  const msg = await HttpUtil.get('/panel/api/inbounds/options', undefined, { silent: true });
+  if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbound options');
+  const validated = parseMsg(msg, InboundOptionsSchema, 'inbounds/options');
+  return Array.isArray(validated.obj) ? validated.obj : [];
+}
+
+export function useInboundOptions() {
+  return useQuery({
+    queryKey: keys.inbounds.options(),
+    queryFn: fetchInboundOptions,
+    staleTime: Infinity,
+  });
+}

+ 1 - 1
frontend/src/pages/clients/BulkAttachInboundsModal.tsx

@@ -36,7 +36,7 @@ export default function BulkAttachInboundsModal({
       .filter((ib) => MULTI_USER_PROTOCOLS.has((ib.protocol || '').toLowerCase()))
       .map((ib) => ({
         value: ib.id,
-        label: ib.tag,
+        label: ib.remark?.trim() || ib.tag || '',
       }));
   }, [inbounds]);
 

+ 1 - 1
frontend/src/pages/clients/BulkDetachInboundsModal.tsx

@@ -36,7 +36,7 @@ export default function BulkDetachInboundsModal({
       .filter((ib) => MULTI_USER_PROTOCOLS.has((ib.protocol || '').toLowerCase()))
       .map((ib) => ({
         value: ib.id,
-        label: ib.tag,
+        label: ib.remark?.trim() || ib.tag || '',
       }));
   }, [inbounds]);
 

+ 1 - 1
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -100,7 +100,7 @@ export default function ClientBulkAddModal({
     () => (inbounds || [])
       .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
       .map((ib) => ({
-        label: ib.tag ?? '',
+        label: ib.remark?.trim() || ib.tag || '',
         value: ib.id,
       })),
     [inbounds],

+ 2 - 2
frontend/src/pages/clients/ClientFormModal.tsx

@@ -261,9 +261,9 @@ export default function ClientFormModal({
     () => (inbounds || [])
       .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
       .map((ib) => ({
-        label: ib.tag ?? '',
+        label: ib.remark?.trim() || ib.tag || '',
         value: ib.id,
-        title: ib.tag ?? '',
+        title: ib.remark?.trim() || ib.tag || '',
       })),
     [inbounds],
   );

+ 1 - 1
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -382,7 +382,7 @@ export default function ClientInfoModal({
                         const ib = inboundsById[id];
                         const proto = (ib?.protocol || '').toLowerCase();
                         const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
-                        const label = ib?.tag ?? '';
+                        const label = ib?.remark?.trim() || ib?.tag || '';
                         return (
                           <Tooltip key={id} title={label}>
                             <Tag color={color}>{label}</Tag>

+ 7 - 0
frontend/src/pages/clients/ClientsPage.css

@@ -33,6 +33,13 @@
   flex: 0 0 auto;
 }
 
+.filter-count {
+  margin-inline-start: auto;
+  color: var(--ant-color-text-secondary);
+  font-size: 13px;
+  white-space: nowrap;
+}
+
 .filter-chips {
   display: flex;
   flex-wrap: wrap;

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

@@ -188,7 +188,7 @@ export default function ClientsPage() {
   useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
 
   const {
-    clients, filtered,
+    clients, total, filtered,
     summary: serverSummary,
     allGroups,
     setQuery,
@@ -304,7 +304,7 @@ export default function ClientsPage() {
 
   function inboundLabel(id: number) {
     const ib = inboundsById[id];
-    return ib?.tag ?? '';
+    return ib?.remark?.trim() || ib?.tag || '';
   }
 
   const clientBucket = useCallback((row: ClientRecord | null | undefined): Bucket | null => {
@@ -694,7 +694,7 @@ export default function ClientsPage() {
           const ib = inboundsById[id];
           const proto = (ib?.protocol || '').toLowerCase();
           const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
-          const compactLabel = ib?.tag ?? '';
+          const compactLabel = ib?.remark?.trim() || ib?.tag || '';
           return (
             <Tooltip key={id} title={inboundLabel(id)}>
               <Tag color={color} style={{ margin: 2 }}>
@@ -993,6 +993,11 @@ export default function ClientsPage() {
                             {t('pages.clients.clearAllFilters')}
                           </Button>
                         )}
+                        {(activeCount > 0 || debouncedSearch.trim().length > 0) && (
+                          <span className="filter-count">
+                            {t('pages.clients.showingCount', { shown: filtered, total })}
+                          </span>
+                        )}
                       </div>
 
                       {activeCount > 0 && (

+ 1 - 1
frontend/src/pages/clients/FilterDrawer.tsx

@@ -50,7 +50,7 @@ export default function FilterDrawer({
   const inboundOptions = useMemo(
     () => inbounds.map((ib) => ({
       value: ib.id,
-      label: ib.tag ?? '',
+      label: ib.remark?.trim() || ib.tag || '',
     })),
     [inbounds],
   );

+ 2 - 2
frontend/src/pages/inbounds/clients/AttachClientsModal.tsx

@@ -69,7 +69,7 @@ export default function AttachClientsModal({
     if (!source) return [];
     return (dbInbounds || [])
       .filter((ib) => ib.id !== source.id && isInboundMultiUser(ib))
-      .map((ib) => ({ value: ib.id, label: ib.tag ?? '' }));
+      .map((ib) => ({ value: ib.id, label: ib.remark?.trim() || ib.tag || '' }));
   }, [dbInbounds, source]);
 
   const filteredRows = useMemo(() => {
@@ -150,7 +150,7 @@ export default function AttachClientsModal({
       }}
       okText={t('pages.inbounds.attachClients')}
       cancelText={t('cancel')}
-      title={t('pages.inbounds.attachClientsTitle', { remark: source?.tag ?? '' })}
+      title={t('pages.inbounds.attachClientsTitle', { remark: source?.remark?.trim() || source?.tag || '' })}
       width={680}
     >
       {messageContextHolder}

+ 1 - 1
frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx

@@ -170,7 +170,7 @@ export default function AttachExistingClientsModal({
       okButtonProps={{ disabled: selectedEmails.length === 0, loading: saving }}
       okText={t('pages.inbounds.attachClients')}
       cancelText={t('cancel')}
-      title={t('pages.inbounds.attachExistingTitle', { remark: target?.tag ?? '' })}
+      title={t('pages.inbounds.attachExistingTitle', { remark: target?.remark?.trim() || target?.tag || '' })}
       width={680}
     >
       {messageContextHolder}

+ 15 - 1
frontend/src/pages/nodes/NodeFormModal.tsx

@@ -65,6 +65,7 @@ export default function NodeFormModal({
   const [testing, setTesting] = useState(false);
   const [fetchingPin, setFetchingPin] = useState(false);
   const [testResult, setTestResult] = useState<ProbeResult | null>(null);
+  const scheme = Form.useWatch('scheme', form) ?? 'https';
   const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify';
 
   useEffect(() => {
@@ -78,6 +79,7 @@ export default function NodeFormModal({
         scheme: (node.scheme as 'http' | 'https') || base.scheme,
       }
       : base;
+    if (next.scheme === 'http') next.tlsVerifyMode = 'skip';
     form.resetFields();
     form.setFieldsValue(next);
     setTestResult(null);
@@ -155,7 +157,15 @@ export default function NodeFormModal({
     }
     setSubmitting(true);
     try {
-      const msg = await save(buildPayload(result.data));
+      const payload = buildPayload(result.data);
+      const test = await testConnection(payload);
+      const probe = test?.success ? test.obj : null;
+      if (!probe || probe.status !== 'online') {
+        setTestResult(probe ?? { status: 'offline', error: test?.msg || t('pages.nodes.connectionFailed') });
+        return;
+      }
+      setTestResult(probe);
+      const msg = await save(payload);
       if (msg?.success) {
         onOpenChange(false);
       }
@@ -213,6 +223,9 @@ export default function NodeFormModal({
                     { value: 'https', label: 'https' },
                     { value: 'http', label: 'http' },
                   ]}
+                  onChange={(value) => {
+                    if (value === 'http') form.setFieldValue('tlsVerifyMode', 'skip');
+                  }}
                 />
               </Form.Item>
             </Col>
@@ -268,6 +281,7 @@ export default function NodeFormModal({
             extra={t('pages.nodes.tlsVerifyModeHint')}
           >
             <Select
+              disabled={scheme === 'http'}
               options={[
                 { value: 'verify', label: t('pages.nodes.tlsVerify') },
                 { value: 'pin', label: t('pages.nodes.tlsPin') },

+ 12 - 2
frontend/src/pages/xray/routing/RuleFormModal.tsx

@@ -1,8 +1,9 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Button, Form, Input, Modal, Select, Space, Tooltip } from 'antd';
 import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
 import { InputAddon } from '@/components/ui';
+import { useInboundOptions } from '@/api/queries/useInboundOptions';
 import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
 
 export interface RoutingRule {
@@ -72,6 +73,15 @@ export default function RuleFormModal({
   const [form, setForm] = useState<FormState>(initialForm);
   const isEdit = rule != null;
 
+  const { data: inboundOptions } = useInboundOptions();
+  const remarkByTag = useMemo(() => {
+    const map: Record<string, string> = {};
+    for (const ib of inboundOptions || []) {
+      if (ib.tag) map[ib.tag] = ib.remark?.trim() || ib.tag;
+    }
+    return map;
+  }, [inboundOptions]);
+
   useEffect(() => {
     if (!open) return;
     if (rule) {
@@ -269,7 +279,7 @@ export default function RuleFormModal({
             mode="multiple"
             value={form.inboundTag}
             onChange={(v) => update('inboundTag', v)}
-            options={inboundTags.map((tag) => ({ value: tag, label: tag }))}
+            options={inboundTags.map((tag) => ({ value: tag, label: remarkByTag[tag] || tag }))}
           />
         </Form.Item>
 

+ 1 - 1
frontend/src/schemas/node.ts

@@ -49,7 +49,7 @@ export const NodeFormSchema = z.object({
   enable: z.boolean(),
   allowPrivateAddress: z.boolean(),
   tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
-  pinnedCertSha256: z.string(),
+  pinnedCertSha256: z.string().optional().default(''),
 });
 
 export type NodeRecord = z.infer<typeof NodeRecordSchema>;

+ 2 - 1
sub/subController.go

@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"net/http"
+	"net/url"
 	"os"
 	"strconv"
 	"strings"
@@ -279,7 +280,7 @@ func (a *SUBController) subClashs(c *gin.Context) {
 		a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules)
 		if a.subTitle != "" {
 			// Clash clients commonly use Content-Disposition to choose the imported profile name.
-			c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, a.subTitle))
+			c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, url.PathEscape(a.subTitle)))
 		}
 		c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub))
 	}

+ 18 - 0
web/controller/node.go

@@ -2,6 +2,7 @@ package controller
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"slices"
 	"strconv"
@@ -63,11 +64,24 @@ func (a *NodeController) get(c *gin.Context) {
 	jsonObj(c, n, nil)
 }
 
+func (a *NodeController) ensureReachable(c *gin.Context, n *model.Node) error {
+	ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
+	defer cancel()
+	if _, err := a.nodeService.Probe(ctx, n); err != nil {
+		return errors.New(service.FriendlyProbeError(err.Error()))
+	}
+	return nil
+}
+
 func (a *NodeController) add(c *gin.Context) {
 	n, ok := middleware.BindAndValidate[model.Node](c)
 	if !ok {
 		return
 	}
+	if err := a.ensureReachable(c, n); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
+		return
+	}
 	if err := a.nodeService.Create(n); err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
 		return
@@ -85,6 +99,10 @@ func (a *NodeController) update(c *gin.Context) {
 	if !ok {
 		return
 	}
+	if err := a.ensureReachable(c, n); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
+		return
+	}
 	if err := a.nodeService.Update(id, n); err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
 		return

+ 15 - 63
web/job/check_client_ip_job.go

@@ -35,23 +35,6 @@ var job *CheckClientIpJob
 
 const defaultXrayAPIPort = 62789
 
-// ipStaleAfterSeconds controls how long a client IP kept in the
-// per-client tracking table (model.InboundClientIps.Ips) is considered
-// still "active" before it's evicted during the next scan.
-//
-// Without this eviction, an IP that connected once and then went away
-// keeps sitting in the table with its old timestamp. Because the
-// excess-IP selector sorts ascending ("newest wins, oldest loses") to
-// protect the most recent connections, that stale entry keeps
-// occupying a slot and the IP that is *actually* currently using the
-// config gets classified as "new excess" and banned by fail2ban on
-// every single run — producing the continuous ban loop from #4077.
-//
-// 30 minutes is chosen so an actively-streaming client (where xray
-// emits a fresh `accepted` log line whenever it opens a new TCP) will
-// always refresh its timestamp well within the window, but a client
-// that has really stopped using the config will drop out of the table
-// in a bounded time and free its slot.
 const ipStaleAfterSeconds = int64(30 * 60)
 
 // NewCheckClientIpJob creates a new client IP monitoring job instance.
@@ -67,27 +50,20 @@ func (j *CheckClientIpJob) Run() {
 
 	shouldClearAccessLog := false
 	fail2BanEnabled := isFail2BanEnabled()
-	iplimitActive := fail2BanEnabled && j.hasLimitIp()
+	hasLimit := fail2BanEnabled && j.hasLimitIp()
 	f2bInstalled := false
-	if iplimitActive {
+	if hasLimit {
 		f2bInstalled = j.checkFail2BanInstalled()
 	}
-	isAccessLogAvailable := j.checkAccessLogAvailable(iplimitActive)
+	isAccessLogAvailable := j.checkAccessLogAvailable(hasLimit)
 
-	if isAccessLogAvailable {
-		if runtime.GOOS == "windows" {
-			if iplimitActive {
-				shouldClearAccessLog = j.processLogFile()
-			}
-		} else {
-			if iplimitActive {
-				if f2bInstalled {
-					shouldClearAccessLog = j.processLogFile()
-				} else {
-					logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
-				}
-			}
+	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)
 	}
 
 	if shouldClearAccessLog || (isAccessLogAvailable && time.Now().Unix()-j.lastClear > 3600) {
@@ -145,7 +121,7 @@ func (j *CheckClientIpJob) hasLimitIp() bool {
 	return false
 }
 
-func (j *CheckClientIpJob) processLogFile() bool {
+func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
 
 	ipRegex := regexp.MustCompile(`from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted`)
 	emailRegex := regexp.MustCompile(`email: (.+)$`)
@@ -220,18 +196,12 @@ func (j *CheckClientIpJob) processLogFile() bool {
 			continue
 		}
 
-		shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ipsWithTime) || shouldCleanLog
+		shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ipsWithTime, enforce) || shouldCleanLog
 	}
 
 	return shouldCleanLog
 }
 
-// mergeClientIps combines the persisted (old) and freshly observed (new)
-// IP-with-timestamp lists for a single client into a map. An entry is
-// dropped if its last-seen timestamp is older than staleCutoff.
-//
-// Extracted as a helper so updateInboundClientIps can stay DB-oriented
-// and the merge policy can be exercised by a unit test.
 func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]int64 {
 	ipMap := make(map[string]int64, len(old)+len(new))
 	for _, ipTime := range old {
@@ -251,19 +221,6 @@ func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]in
 	return ipMap
 }
 
-// partitionLiveIps splits the merged ip map into live (seen in the
-// current scan) and historical (only in the db blob, still inside the
-// staleness window).
-//
-// only live ips count toward the per-client limit. historical ones stay
-// in the db so the panel keeps showing them, but they must not take a
-// live slot or get re-banned. the 30min cutoff alone isn't tight enough:
-// an ip that stopped connecting a few minutes ago still looks fresh to
-// mergeClientIps, and without this split it would keep triggering
-// fail2ban even though it isn't currently connected. see #4077 / #4091.
-//
-// live is sorted ascending by timestamp (oldest → newest), so we keep
-// the most recent entries at the end of the slice (last IP wins).
 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))
@@ -354,7 +311,7 @@ func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ipsWithTime [
 	return nil
 }
 
-func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, newIpsWithTime []IPWithTimestamp) bool {
+func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce bool) bool {
 	// Get the inbound configuration
 	inbound, err := j.getInboundByEmail(clientEmail)
 	if err != nil {
@@ -382,8 +339,9 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
 		}
 	}
 
-	if !clientFound || limitIp <= 0 || !inbound.Enable {
-		// No limit or inbound disabled, just update and return
+	if !enforce || !clientFound || limitIp <= 0 || !inbound.Enable {
+		// Nothing to enforce (collection-only run, no limit, client missing, or
+		// inbound disabled): record the observed IPs for the panel and return.
 		jsonIps, _ := json.Marshal(newIpsWithTime)
 		inboundClientIps.Ips = string(jsonIps)
 		db := database.GetDB()
@@ -397,8 +355,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
 		json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
 	}
 
-	// Merge old and new IPs, evicting entries that haven't been
-	// re-observed in a while. See mergeClientIps / #4077 for why.
 	ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, time.Now().Unix()-ipStaleAfterSeconds)
 
 	// only ips seen in this scan count toward the limit. see
@@ -422,10 +378,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
 		keptLive = liveIps[cutoff:]
 		bannedLive := liveIps[:cutoff]
 
-		// Open log file only when a ban entry needs to be written.
-		// Use a local logger to avoid mutating the global log.* state,
-		// which would redirect all standard-library logging to this file
-		// and leave a dangling closed-file handle after the defer fires.
 		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)

+ 51 - 2
web/job/check_client_ip_job_integration_test.go

@@ -195,7 +195,7 @@ func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testin
 		{IP: "128.71.1.1", Timestamp: now},
 	}
 
-	shouldCleanLog := j.updateInboundClientIps(row, email, live)
+	shouldCleanLog := j.updateInboundClientIps(row, email, live, true)
 
 	if shouldCleanLog {
 		t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
@@ -244,7 +244,7 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
 		{IP: "192.0.2.9", Timestamp: now},
 	}
 
-	shouldCleanLog := j.updateInboundClientIps(row, email, live)
+	shouldCleanLog := j.updateInboundClientIps(row, email, live, true)
 
 	if !shouldCleanLog {
 		t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
@@ -272,6 +272,55 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
 	}
 }
 
+// writeXrayAccessLog points bin/config.json at a fresh access.log holding a
+// single default-format Xray line (`from tcp:<ip>:<port> accepted … email: <e>`)
+// for the given client, so Run() has something to scrape.
+func writeXrayAccessLog(t *testing.T, email, ip string) {
+	t.Helper()
+	binDir := t.TempDir()
+	accessLog := filepath.Join(t.TempDir(), "access.log")
+	t.Setenv("XUI_BIN_FOLDER", binDir)
+	configData, err := json.Marshal(map[string]any{
+		"log": map[string]any{"access": accessLog},
+	})
+	if err != nil {
+		t.Fatalf("marshal xray config: %v", err)
+	}
+	if err := os.WriteFile(filepath.Join(binDir, "config.json"), configData, 0644); err != nil {
+		t.Fatalf("write xray config: %v", err)
+	}
+	line := "2026/06/02 13:35:53 from tcp:" + ip + ":2387 accepted tcp:example.com:443 email: " + email + "\n"
+	if err := os.WriteFile(accessLog, []byte(line), 0644); err != nil {
+		t.Fatalf("write access log: %v", err)
+	}
+}
+
+// #4800: the per-client IP log must populate even when no client has an IP
+// limit. Before the fix, Run() only scraped the access log when an IP limit
+// was active, so a limit-free install always showed an empty IP log despite
+// valid access-log lines. No ban may be written since there's no limit.
+func TestRun_CollectsIpsWithoutLimit(t *testing.T) {
+	setupIntegrationDB(t)
+	t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
+	fakeFail2BanClient(t)
+
+	const email = "no-limit-user"
+	seedInboundWithClient(t, "inbound-no-limit", email, 0) // limitIp = 0
+	writeXrayAccessLog(t, email, "203.0.113.10")
+
+	NewCheckClientIpJob().Run()
+
+	ips := readClientIps(t, email)
+	if len(ips) != 1 || ips[0].IP != "203.0.113.10" {
+		t.Fatalf("expected the access-log IP to be collected without a limit, got %v", ips)
+	}
+
+	if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
+		body, _ := os.ReadFile(readIpLimitLogPath())
+		t.Fatalf("3xipl.log should be empty with no limit set, got:\n%s", body)
+	}
+}
+
 // readIpLimitLogPath reads the 3xipl.log path the same way the job
 // does via xray.GetIPLimitLogPath but without importing xray here
 // just for the path helper (which would pull a lot more deps into the

+ 8 - 1
web/service/node.go

@@ -562,7 +562,7 @@ func (p HeartbeatPatch) ToUI(ok bool) ProbeResultUI {
 		CpuPct:       p.CpuPct,
 		MemPct:       p.MemPct,
 		UptimeSecs:   p.UptimeSecs,
-		Error:        p.LastError,
+		Error:        FriendlyProbeError(p.LastError),
 	}
 	if ok {
 		r.Status = "online"
@@ -571,3 +571,10 @@ func (p HeartbeatPatch) ToUI(ok bool) ProbeResultUI {
 	}
 	return r
 }
+
+func FriendlyProbeError(msg string) string {
+	if strings.Contains(msg, "server gave HTTP response to HTTPS client") {
+		return "the server speaks HTTP, not HTTPS; set the node scheme to http"
+	}
+	return msg
+}

+ 1 - 0
web/translation/ar-EG.json

@@ -643,6 +643,7 @@
       "searchPlaceholder": "ابحث بالبريد، التعليق، sub ID، UUID، كلمة المرور، auth…",
       "filterTitle": "تصفية العملاء",
       "clearAllFilters": "مسح الكل",
+      "showingCount": "عرض {shown} من {total}",
       "sortOldest": "الأقدم أولاً",
       "sortNewest": "الأحدث أولاً",
       "sortRecentlyUpdated": "محدّث مؤخراً",

+ 1 - 0
web/translation/en-US.json

@@ -643,6 +643,7 @@
       "searchPlaceholder": "Search email, comment, sub ID, UUID, password, auth…",
       "filterTitle": "Filter clients",
       "clearAllFilters": "Clear all",
+      "showingCount": "Showing {shown} of {total}",
       "sortOldest": "Oldest first",
       "sortNewest": "Newest first",
       "sortRecentlyUpdated": "Recently updated",

+ 1 - 0
web/translation/es-ES.json

@@ -643,6 +643,7 @@
       "searchPlaceholder": "Buscar email, comentario, sub ID, UUID, contraseña, auth…",
       "filterTitle": "Filtrar clientes",
       "clearAllFilters": "Limpiar todo",
+      "showingCount": "Mostrando {shown} de {total}",
       "sortOldest": "Más antiguos",
       "sortNewest": "Más recientes",
       "sortRecentlyUpdated": "Recientemente actualizados",

+ 1 - 0
web/translation/fa-IR.json

@@ -643,6 +643,7 @@
       "searchPlaceholder": "جستجوی ایمیل، توضیح، Sub ID، UUID، رمز، احراز...",
       "filterTitle": "فیلتر کاربران",
       "clearAllFilters": "پاک کردن همه",
+      "showingCount": "نمایش {shown} از {total}",
       "sortOldest": "قدیمی‌ترین",
       "sortNewest": "جدیدترین",
       "sortRecentlyUpdated": "اخیراً به‌روزشده",

+ 1 - 0
web/translation/id-ID.json

@@ -643,6 +643,7 @@
       "searchPlaceholder": "Cari email, komentar, sub ID, UUID, kata sandi, auth…",
       "filterTitle": "Filter klien",
       "clearAllFilters": "Hapus semua",
+      "showingCount": "Menampilkan {shown} dari {total}",
       "sortOldest": "Terlama dulu",
       "sortNewest": "Terbaru dulu",
       "sortRecentlyUpdated": "Baru saja diperbarui",

+ 1 - 0
web/translation/ja-JP.json

@@ -643,6 +643,7 @@
       "searchPlaceholder": "メール、コメント、sub ID、UUID、パスワード、auth を検索…",
       "filterTitle": "クライアントをフィルタ",
       "clearAllFilters": "すべてクリア",
+      "showingCount": "{total} 件中 {shown} 件を表示",
       "sortOldest": "古い順",
       "sortNewest": "新しい順",
       "sortRecentlyUpdated": "最近更新",

+ 1 - 0
web/translation/pt-BR.json

@@ -643,6 +643,7 @@
       "searchPlaceholder": "Buscar email, comentário, sub ID, UUID, senha, auth…",
       "filterTitle": "Filtrar clientes",
       "clearAllFilters": "Limpar tudo",
+      "showingCount": "Mostrando {shown} de {total}",
       "sortOldest": "Mais antigos primeiro",
       "sortNewest": "Mais novos primeiro",
       "sortRecentlyUpdated": "Atualizados recentemente",

+ 1 - 0
web/translation/ru-RU.json

@@ -643,6 +643,7 @@
       "searchPlaceholder": "Поиск email, комментария, sub ID, UUID, пароля, auth…",
       "filterTitle": "Фильтр клиентов",
       "clearAllFilters": "Очистить все",
+      "showingCount": "Показано {shown} из {total}",
       "sortOldest": "Сначала старые",
       "sortNewest": "Сначала новые",
       "sortRecentlyUpdated": "Недавно обновлены",

+ 1 - 0
web/translation/tr-TR.json

@@ -643,6 +643,7 @@
       "searchPlaceholder": "Email, yorum, sub ID, UUID, parola, auth ara…",
       "filterTitle": "İstemcileri filtrele",
       "clearAllFilters": "Tümünü temizle",
+      "showingCount": "{total} içinden {shown} gösteriliyor",
       "sortOldest": "Önce en eski",
       "sortNewest": "Önce en yeni",
       "sortRecentlyUpdated": "Son güncellenen",

+ 1 - 0
web/translation/uk-UA.json

@@ -643,6 +643,7 @@
       "searchPlaceholder": "Пошук email, коментаря, sub ID, UUID, паролю, auth…",
       "filterTitle": "Фільтр клієнтів",
       "clearAllFilters": "Очистити все",
+      "showingCount": "Показано {shown} з {total}",
       "sortOldest": "Спочатку старі",
       "sortNewest": "Спочатку нові",
       "sortRecentlyUpdated": "Нещодавно оновлені",

+ 1 - 0
web/translation/vi-VN.json

@@ -643,6 +643,7 @@
       "searchPlaceholder": "Tìm email, ghi chú, sub ID, UUID, mật khẩu, auth…",
       "filterTitle": "Lọc client",
       "clearAllFilters": "Xóa tất cả",
+      "showingCount": "Hiển thị {shown} trên {total}",
       "sortOldest": "Cũ nhất trước",
       "sortNewest": "Mới nhất trước",
       "sortRecentlyUpdated": "Gần đây cập nhật",

+ 1 - 0
web/translation/zh-CN.json

@@ -643,6 +643,7 @@
       "searchPlaceholder": "搜索邮箱、备注、sub ID、UUID、密码、auth…",
       "filterTitle": "筛选客户端",
       "clearAllFilters": "清除全部",
+      "showingCount": "显示 {shown} / {total}",
       "sortOldest": "最旧优先",
       "sortNewest": "最新优先",
       "sortRecentlyUpdated": "最近更新",

+ 1 - 0
web/translation/zh-TW.json

@@ -643,6 +643,7 @@
       "searchPlaceholder": "搜尋電子郵件、備註、sub ID、UUID、密碼、auth…",
       "filterTitle": "篩選客戶端",
       "clearAllFilters": "清除全部",
+      "showingCount": "顯示 {shown} / {total}",
       "sortOldest": "最舊優先",
       "sortNewest": "最新優先",
       "sortRecentlyUpdated": "最近更新",