소스 검색

feat(inbounds): clearer client validation errors on save

When an inbound save fails Zod validation, the toast previously showed a
raw path like `settings.clients.494.tgId: Invalid input`, which gave no
hint which of hundreds of clients was at fault. Resolve the client array
index back to the client email, name the field, and append a "(+N more)"
count when several fields fail. console.error now logs a readable list of
every issue instead of dumping the whole form.

Adds the invalidClientField/invalidField/moreIssues toast strings across
all 13 translations.
MHSanaei 15 시간 전
부모
커밋
76dbbfc1f8

+ 7 - 12
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -48,6 +48,7 @@ import { FinalMaskForm } from '@/lib/xray/forms/transport';
 import './InboundFormModal.css';
 
 import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors';
+import { formatInboundIssue, formatInboundValidation } from './formatValidationError';
 import {
   HttpFields,
   HysteriaFields,
@@ -360,18 +361,12 @@ export default function InboundFormModal({
     const values = form.getFieldsValue(true) as InboundFormValues;
     const parsed = InboundFormSchema.safeParse(values);
     if (!parsed.success) {
-      const issue = parsed.error.issues[0];
-      const path = Array.isArray(issue?.path) && issue.path.length > 0
-        ? issue.path.join('.')
-        : '';
-      const baseMsg = issue?.message ?? 'somethingWentWrong';
-      const display = path ? `${path}: ${baseMsg}` : baseMsg;
-      messageApi.error(t(baseMsg, { defaultValue: display }));
-      console.error('[InboundFormModal] schema validation failed', {
-        path: issue?.path,
-        message: issue?.message,
-        values,
-      });
+      const issues = parsed.error.issues;
+      messageApi.error(formatInboundValidation(issues, values, t));
+      console.error(
+        '[InboundFormModal] schema validation failed:',
+        issues.map((issue) => formatInboundIssue(issue, values, t)),
+      );
       return;
     }
     setSaving(true);

+ 43 - 0
frontend/src/pages/inbounds/form/formatValidationError.ts

@@ -0,0 +1,43 @@
+import type { TFunction } from 'i18next';
+
+type IssueLike = { path: PropertyKey[]; message: string };
+
+interface ClientLike {
+  email?: unknown;
+}
+
+/**
+ * Turns one Zod issue from the inbound-form schema into a human-readable line.
+ * The schema validates the whole form at once, so a bad client field surfaces
+ * as `settings.clients.<index>.<field>` — useless on its own when an inbound
+ * holds hundreds of clients. We resolve that index back to the client's email
+ * so the operator can find the offending entry. The reason is translated when
+ * it is a custom message key; Zod defaults like "Invalid input" pass through.
+ */
+export function formatInboundIssue(issue: IssueLike, values: unknown, t: TFunction): string {
+  const path = Array.isArray(issue?.path) ? issue.path : [];
+  const reason = t(issue?.message, { defaultValue: issue?.message });
+
+  if (path[0] === 'settings' && path[1] === 'clients' && typeof path[2] === 'number') {
+    const index = path[2];
+    const clients = (values as { settings?: { clients?: ClientLike[] } })?.settings?.clients;
+    const client = Array.isArray(clients) ? clients[index] : undefined;
+    const email = typeof client?.email === 'string' && client.email !== '' ? client.email : '';
+    const who = email ? `"${email}"` : `#${index}`;
+    const field = path.slice(3).map(String).join('.') || t('clients');
+    return t('pages.inbounds.toasts.invalidClientField', { client: who, field, reason });
+  }
+
+  const field = path.map(String).join('.') || 'value';
+  return t('pages.inbounds.toasts.invalidField', { field, reason });
+}
+
+/**
+ * Builds the single-line toast for a failed inbound save: the first issue,
+ * fully described, plus a "(+N more)" tail when several fields failed.
+ */
+export function formatInboundValidation(issues: IssueLike[], values: unknown, t: TFunction): string {
+  const first = formatInboundIssue(issues[0], values, t);
+  if (issues.length <= 1) return first;
+  return t('pages.inbounds.toasts.moreIssues', { message: first, count: issues.length - 1 });
+}

+ 65 - 0
frontend/src/test/format-validation-error.test.ts

@@ -0,0 +1,65 @@
+/// <reference types="vite/client" />
+import { describe, expect, it } from 'vitest';
+import { z } from 'zod';
+import type { TFunction } from 'i18next';
+
+import { formatInboundIssue, formatInboundValidation } from '@/pages/inbounds/form/formatValidationError';
+
+const templates: Record<string, string> = {
+  'pages.inbounds.toasts.invalidClientField': 'Client {client}: {field} — {reason}',
+  'pages.inbounds.toasts.invalidField': '{field} — {reason}',
+  'pages.inbounds.toasts.moreIssues': '{message}  (+{count} more)',
+  clients: 'clients',
+};
+
+const t = ((key: string, opts?: Record<string, unknown>) => {
+  let out = templates[key] ?? (opts?.defaultValue as string | undefined) ?? key;
+  if (opts) {
+    for (const [k, v] of Object.entries(opts)) {
+      out = out.split(`{${k}}`).join(String(v));
+    }
+  }
+  return out;
+}) as unknown as TFunction;
+
+describe('formatInboundValidation', () => {
+  it('resolves a real client array index back to the client email', () => {
+    const schema = z.object({
+      settings: z.object({
+        clients: z.array(z.object({ email: z.string(), tgId: z.number() })),
+      }),
+    });
+    const values = {
+      settings: {
+        clients: [
+          { email: '[email protected]', tgId: 1 },
+          { email: '[email protected]', tgId: 'oops' },
+        ],
+      },
+    };
+    const parsed = schema.safeParse(values);
+    expect(parsed.success).toBe(false);
+    if (parsed.success) return;
+    expect(formatInboundIssue(parsed.error.issues[0], values, t)).toContain('Client "[email protected]": tgId — ');
+  });
+
+  it('falls back to the index when the client has no email', () => {
+    const issue = { path: ['settings', 'clients', 7, 'tgId'], message: 'Invalid input' };
+    const values = { settings: { clients: [] } };
+    expect(formatInboundIssue(issue, values, t)).toBe('Client #7: tgId — Invalid input');
+  });
+
+  it('formats non-client paths plainly', () => {
+    const issue = { path: ['port'], message: 'Invalid input' };
+    expect(formatInboundIssue(issue, {}, t)).toBe('port — Invalid input');
+  });
+
+  it('appends a count when several fields fail', () => {
+    const issues = [
+      { path: ['settings', 'clients', 0, 'tgId'], message: 'Invalid input' },
+      { path: ['port'], message: 'Invalid input' },
+    ];
+    const values = { settings: { clients: [{ email: '[email protected]' }] } };
+    expect(formatInboundValidation(issues, values, t)).toBe('Client "[email protected]": tgId — Invalid input  (+1 more)');
+  });
+});

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

@@ -442,7 +442,10 @@
         "trafficGetError": "خطأ في الحصول على حركات المرور",
         "getNewX25519CertError": "حدث خطأ أثناء الحصول على شهادة X25519.",
         "getNewmldsa65Error": "حدث خطاء في الحصول على mldsa65.",
-        "getNewVlessEncError": "حدث خطأ أثناء الحصول على VlessEnc."
+        "getNewVlessEncError": "حدث خطأ أثناء الحصول على VlessEnc.",
+        "invalidClientField": "العميل {client}: الحقل {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (+{count} أخرى)"
       },
       "form": {
         "moveUp": "أعلى",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "Error getting traffic.",
         "getNewX25519CertError": "Error while obtaining the X25519 certificate.",
         "getNewmldsa65Error": "Error while obtaining mldsa65.",
-        "getNewVlessEncError": "Error while obtaining VlessEnc."
+        "getNewVlessEncError": "Error while obtaining VlessEnc.",
+        "invalidClientField": "Client {client}: {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (+{count} more)"
       },
       "form": {
         "moveUp": "Move up",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "Error al obtener los tráficos",
         "getNewX25519CertError": "Error al obtener el certificado X25519.",
         "getNewmldsa65Error": "Error al obtener el certificado mldsa65.",
-        "getNewVlessEncError": "Error al obtener el certificado VlessEnc."
+        "getNewVlessEncError": "Error al obtener el certificado VlessEnc.",
+        "invalidClientField": "Cliente {client}: campo {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (+{count} más)"
       },
       "form": {
         "moveUp": "Subir",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "خطا در دریافت ترافیک‌ها",
         "getNewX25519CertError": "خطا در دریافت گواهی X25519.",
         "getNewmldsa65Error": "خطا در دریافت گواهی mldsa65.",
-        "getNewVlessEncError": "خطا در دریافت گواهی VlessEnc."
+        "getNewVlessEncError": "خطا در دریافت گواهی VlessEnc.",
+        "invalidClientField": "کلاینت {client}: فیلد {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (+{count} مورد دیگر)"
       },
       "form": {
         "moveUp": "بالا",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "Gagal mendapatkan data lalu lintas",
         "getNewX25519CertError": "Terjadi kesalahan saat mendapatkan sertifikat X25519.",
         "getNewmldsa65Error": "Terjadi kesalahan saat mendapatkan sertifikat mldsa65.",
-        "getNewVlessEncError": "Terjadi kesalahan saat mendapatkan sertifikat VlessEnc."
+        "getNewVlessEncError": "Terjadi kesalahan saat mendapatkan sertifikat VlessEnc.",
+        "invalidClientField": "Klien {client}: kolom {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (+{count} lainnya)"
       },
       "form": {
         "moveUp": "Naik",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "トラフィックの取得中にエラーが発生しました",
         "getNewX25519CertError": "X25519証明書の取得中にエラーが発生しました。",
         "getNewmldsa65Error": "mldsa65証明書の取得中にエラーが発生しました。",
-        "getNewVlessEncError": "VlessEnc証明書の取得中にエラーが発生しました。"
+        "getNewVlessEncError": "VlessEnc証明書の取得中にエラーが発生しました。",
+        "invalidClientField": "クライアント {client}: フィールド {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (他 {count} 件)"
       },
       "form": {
         "moveUp": "上へ",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "Erro ao obter tráfegos",
         "getNewX25519CertError": "Erro ao obter o certificado X25519.",
         "getNewmldsa65Error": "Erro ao obter o certificado mldsa65.",
-        "getNewVlessEncError": "Erro ao obter o certificado VlessEnc."
+        "getNewVlessEncError": "Erro ao obter o certificado VlessEnc.",
+        "invalidClientField": "Cliente {client}: campo {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (+{count} mais)"
       },
       "form": {
         "moveUp": "Mover para cima",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "Ошибка получения данных о трафике",
         "getNewX25519CertError": "Ошибка при получении сертификата X25519.",
         "getNewmldsa65Error": "Ошибка при получении сертификата mldsa65.",
-        "getNewVlessEncError": "Ошибка при получении сертификата VlessEnc."
+        "getNewVlessEncError": "Ошибка при получении сертификата VlessEnc.",
+        "invalidClientField": "Клиент {client}: поле {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (+{count} ещё)"
       },
       "form": {
         "moveUp": "Вверх",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "Trafik bilgisi alınırken hata oluştu",
         "getNewX25519CertError": "X25519 sertifikası alınırken hata oluştu.",
         "getNewmldsa65Error": "mldsa65 sertifikası alınırken hata oluştu.",
-        "getNewVlessEncError": "VlessEnc sertifikası alınırken hata oluştu."
+        "getNewVlessEncError": "VlessEnc sertifikası alınırken hata oluştu.",
+        "invalidClientField": "Müşteri {client}: alan {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (+{count} tane daha)"
       },
       "form": {
         "moveUp": "Yukarı",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "Помилка отримання даних про трафік",
         "getNewX25519CertError": "Помилка при отриманні сертифіката X25519.",
         "getNewmldsa65Error": "Помилка при отриманні сертифіката mldsa65.",
-        "getNewVlessEncError": "Помилка при отриманні сертифіката VlessEnc."
+        "getNewVlessEncError": "Помилка при отриманні сертифіката VlessEnc.",
+        "invalidClientField": "Клієнт {client}: поле {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (+{count} ще)"
       },
       "form": {
         "moveUp": "Вгору",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "Lỗi khi lấy thông tin lưu lượng",
         "getNewX25519CertError": "Lỗi khi lấy chứng chỉ X25519.",
         "getNewmldsa65Error": "Lỗi khi lấy chứng chỉ mldsa65.",
-        "getNewVlessEncError": "Lỗi khi lấy chứng chỉ VlessEnc."
+        "getNewVlessEncError": "Lỗi khi lấy chứng chỉ VlessEnc.",
+        "invalidClientField": "Khách hàng {client}: trường {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (+{count} lỗi khác)"
       },
       "form": {
         "moveUp": "Lên",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "获取流量数据时出错",
         "getNewX25519CertError": "获取X25519证书时出错。",
         "getNewmldsa65Error": "获取mldsa65证书时出错。",
-        "getNewVlessEncError": "获取VlessEnc证书时出错。"
+        "getNewVlessEncError": "获取VlessEnc证书时出错。",
+        "invalidClientField": "客户端 {client}:字段 {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (另有 {count} 项)"
       },
       "form": {
         "moveUp": "上移",

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

@@ -442,7 +442,10 @@
         "trafficGetError": "取得流量資料時發生錯誤",
         "getNewX25519CertError": "取得X25519憑證時發生錯誤。",
         "getNewmldsa65Error": "取得mldsa65憑證時發生錯誤。",
-        "getNewVlessEncError": "取得VlessEnc憑證時發生錯誤。"
+        "getNewVlessEncError": "取得VlessEnc憑證時發生錯誤。",
+        "invalidClientField": "用戶端 {client}:欄位 {field} — {reason}",
+        "invalidField": "{field} — {reason}",
+        "moreIssues": "{message}  (另有 {count} 項)"
       },
       "form": {
         "moveUp": "上移",