소스 검색

fix(node): fix "invalid input" on save and gate save on connectivity

The pinnedCertSha256 form field unmounts for non-pin TLS modes, so antd dropped it from the onFinish values and Zod rejected the missing string (the user-facing "invalid input"). Make it optional with a default so saving works in every TLS mode.

Saving now runs the connection test first and only persists when the probe is online; the add/update endpoints enforce the same probe so an unreachable node cannot be stored via the API either.

Selecting the http scheme forces TLS verify mode to skip and disables the control, normalized on open for existing http nodes.

http-vs-https probe failures report a clear "set the node scheme to http" message across the test button, save, and the backend gate.

Closes #4794
MHSanaei 15 시간 전
부모
커밋
02043a432d
4개의 변경된 파일42개의 추가작업 그리고 3개의 파일을 삭제
  1. 15 1
      frontend/src/pages/nodes/NodeFormModal.tsx
  2. 1 1
      frontend/src/schemas/node.ts
  3. 18 0
      web/controller/node.go
  4. 8 1
      web/service/node.go

+ 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') },

+ 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>;

+ 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

+ 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
+}