6 커밋 5b80d4562d ... 0a30a03cb7

작성자 SHA1 메시지 날짜
  n0ctal 0a30a03cb7 refactor(frontend): remove unused response envelope schema (#6204) 12 시간 전
  n0ctal 286a93474d refactor(frontend): remove unreachable barrel modules (#6205) 12 시간 전
  n0ctal 238e4bb314 refactor(tgbot): share numeric keypad transitions (#6211) 12 시간 전
  n0ctal 4a5f6771b3 fix(nodes): report probe heartbeat persistence failures (#6207) 12 시간 전
  n0ctal 64f4f0746c fix(warp): surface update-clock persistence failures (#6209) 12 시간 전
  n0ctal 79ef85b59f refactor(frontend): remove unused legacy utilities (#6206) 12 시간 전

+ 0 - 2
frontend/src/components/feedback/index.ts

@@ -1,2 +0,0 @@
-export { default as PromptModal } from './PromptModal';
-export { default as TextModal } from './TextModal';

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

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

+ 0 - 4
frontend/src/pages/inbounds/clients/index.ts

@@ -1,4 +0,0 @@
-export { default as AttachClientsModal } from './AttachClientsModal';
-export { default as AttachExistingClientsModal } from './AttachExistingClientsModal';
-export { default as DetachClientsModal } from './DetachClientsModal';
-export { default as AddClientsToGroupModal } from './AddClientsToGroupModal';

+ 0 - 1
frontend/src/pages/inbounds/form/index.ts

@@ -1 +0,0 @@
-export { default as InboundFormModal } from './InboundFormModal';

+ 0 - 1
frontend/src/pages/inbounds/info/index.ts

@@ -1 +0,0 @@
-export { default as InboundInfoModal } from './InboundInfoModal';

+ 0 - 10
frontend/src/schemas/_envelope.ts

@@ -1,10 +0,0 @@
-import { z } from 'zod';
-
-export const msgSchema = <T extends z.ZodType>(obj: T) =>
-  z.object({
-    success: z.boolean(),
-    msg: z.string().default(''),
-    obj: obj.nullable(),
-  });
-
-export type MsgOf<S extends z.ZodType> = z.infer<ReturnType<typeof msgSchema<S>>>;

+ 0 - 2
frontend/src/schemas/index.ts

@@ -1,2 +0,0 @@
-export * from './primitives';
-export * from './protocols';

+ 0 - 2
frontend/src/schemas/protocols/shared/index.ts

@@ -1,2 +0,0 @@
-export * from './shadowsocks';
-export * from './vmess';

+ 0 - 42
frontend/src/utils/index.ts

@@ -718,16 +718,6 @@ export class NumberFormatter {
   }
 }
 
-export class Utils {
-  static debounce<A extends unknown[]>(fn: (...args: A) => unknown, delay: number): (...args: A) => void {
-    let timeoutID: ReturnType<typeof setTimeout> | null = null;
-    return function (this: unknown, ...args: A) {
-      if (timeoutID !== null) clearTimeout(timeoutID);
-      timeoutID = setTimeout(() => fn.apply(this, args), delay);
-    };
-  }
-}
-
 export class CookieManager {
   static getCookie(cname: string): string {
     const name = cname + '=';
@@ -814,38 +804,6 @@ export class ColorUtils {
   }
 }
 
-export class ArrayUtils {
-  static doAllItemsExist<T>(array1: T[], array2: T[]): boolean {
-    return array1.every((item) => array2.includes(item));
-  }
-}
-
-export interface BuildURLOptions {
-  host?: string;
-  port?: string;
-  isTLS?: boolean;
-  base: string;
-  path: string;
-}
-
-export class URLBuilder {
-  static buildURL({ host, port, isTLS, base, path }: BuildURLOptions): string {
-    if (!host || host.length === 0) host = window.location.hostname;
-    if (!port || port.length === 0) port = window.location.port;
-    if (isTLS === undefined) isTLS = window.location.protocol === 'https:';
-
-    const protocol = isTLS ? 'https:' : 'http:';
-    let portPart = String(port);
-    if (portPart === '' || (isTLS && portPart === '443') || (!isTLS && portPart === '80')) {
-      portPart = '';
-    } else {
-      portPart = `:${portPart}`;
-    }
-
-    return `${protocol}//${host}${portPart}${base}${path}`;
-  }
-}
-
 export interface SupportedLanguage {
   name: string;
   value: string;

+ 4 - 1
internal/web/controller/node.go

@@ -314,7 +314,10 @@ func (a *NodeController) probe(c *gin.Context) {
 	} else {
 		patch.Status = "online"
 	}
-	_ = a.nodeService.UpdateHeartbeat(id, patch)
+	if err := a.nodeService.UpdateHeartbeat(id, patch); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
+		return
+	}
 	jsonObj(c, patch.ToUI(probeErr == nil), nil)
 }
 

+ 49 - 0
internal/web/controller/node_credentials_writeonly_test.go

@@ -2,6 +2,7 @@ package controller
 
 import (
 	"encoding/json"
+	"errors"
 	"net"
 	"net/http"
 	"net/http/httptest"
@@ -12,6 +13,8 @@ import (
 
 	"github.com/gin-gonic/gin"
 
+	"gorm.io/gorm"
+
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
@@ -66,6 +69,52 @@ func TestNodeControllerResponsesDoNotLeakApiToken(t *testing.T) {
 	}
 }
 
+func TestNodeControllerProbeReportsHeartbeatPersistenceFailure(t *testing.T) {
+	engine := newNodeCredentialTestEngine(t)
+	remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":1,"mem":{"current":1,"total":2},"xray":{"version":"1","state":"running"},"panelVersion":"v3.6.0","panelGuid":"guid","uptime":7,"netIO":{"up":3,"down":4}}}`))
+	}))
+	defer remote.Close()
+	host, portString, err := net.SplitHostPort(strings.TrimPrefix(remote.URL, "http://"))
+	if err != nil {
+		t.Fatalf("split remote addr: %v", err)
+	}
+	port, err := strconv.Atoi(portString)
+	if err != nil {
+		t.Fatalf("parse remote port: %v", err)
+	}
+	node := &model.Node{Scheme: "http", Address: host, Port: port, BasePath: "/", Enable: true, AllowPrivateAddress: true}
+	if err := database.GetDB().Create(node).Error; err != nil {
+		t.Fatalf("seed node: %v", err)
+	}
+
+	db := database.GetDB()
+	const callback = "test:fail_node_heartbeat_update"
+	errInjected := errors.New("injected heartbeat persistence failure")
+	if err := db.Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) {
+		if tx.Statement != nil && tx.Statement.Table == "nodes" {
+			tx.AddError(errInjected)
+		}
+	}); err != nil {
+		t.Fatalf("register update callback: %v", err)
+	}
+	t.Cleanup(func() {
+		if err := db.Callback().Update().Remove(callback); err != nil {
+			t.Errorf("remove update callback: %v", err)
+		}
+	})
+
+	w := httptest.NewRecorder()
+	engine.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/panel/api/nodes/probe/"+strconv.Itoa(node.Id), nil))
+	if !strings.Contains(w.Body.String(), `"success":false`) {
+		t.Fatalf("probe reported success despite heartbeat persistence failure: %s", w.Body.String())
+	}
+	if !strings.Contains(w.Body.String(), errInjected.Error()) {
+		t.Fatalf("probe response omitted persistence error: %s", w.Body.String())
+	}
+}
+
 func TestNodeControllerAddAcceptsTokenButReturnsView(t *testing.T) {
 	engine := newNodeCredentialTestEngine(t)
 

+ 2 - 2
internal/web/controller/xray_setting.go

@@ -190,7 +190,7 @@ func (a *XraySettingController) warp(c *gin.Context) {
 			a.XrayService.SetToNeedRestart()
 			// Restart the auto-update clock so a scheduled rotation
 			// doesn't fire right after this manual one.
-			_ = a.SettingService.SetWarpLastUpdate(time.Now().Unix())
+			err = a.SettingService.SetWarpLastUpdate(time.Now().Unix())
 		}
 	case "license":
 		license := c.PostForm("license")
@@ -202,7 +202,7 @@ func (a *XraySettingController) warp(c *gin.Context) {
 		} else if err = a.SettingService.SetWarpUpdateInterval(interval); err == nil && interval > 0 {
 			// Count the interval from now rather than from epoch 0,
 			// otherwise the job would rotate on its next tick.
-			_ = a.SettingService.SetWarpLastUpdate(time.Now().Unix())
+			err = a.SettingService.SetWarpLastUpdate(time.Now().Unix())
 		}
 	}
 

+ 71 - 0
internal/web/controller/xray_setting_warp_test.go

@@ -0,0 +1,71 @@
+package controller
+
+import (
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"gorm.io/gorm"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
+)
+
+func TestWarpIntervalReportsClockPersistenceFailure(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+	db := database.GetDB()
+	for _, setting := range []*model.Setting{
+		{Key: "warpUpdateInterval", Value: "0"},
+		{Key: "warpLastUpdate", Value: "0"},
+	} {
+		if err := db.Create(setting).Error; err != nil {
+			t.Fatalf("seed %s: %v", setting.Key, err)
+		}
+	}
+
+	const callback = "test:fail_warp_clock_update"
+	errInjected := errors.New("injected WARP clock persistence failure")
+	if err := db.Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) {
+		setting, ok := tx.Statement.Model.(*model.Setting)
+		if ok && setting.Key == "warpLastUpdate" {
+			tx.AddError(errInjected)
+		}
+	}); err != nil {
+		t.Fatalf("register update callback: %v", err)
+	}
+	t.Cleanup(func() {
+		if err := db.Callback().Update().Remove(callback); err != nil {
+			t.Errorf("remove update callback: %v", err)
+		}
+	})
+
+	engine := gin.New()
+	engine.Use(func(c *gin.Context) {
+		c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key })
+		c.Next()
+	})
+	NewXraySettingController(engine.Group("/panel/api"))
+	form := url.Values{"interval": {"7"}}
+	req := httptest.NewRequest(http.MethodPost, "/panel/api/xray/warp/interval", strings.NewReader(form.Encode()))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	w := httptest.NewRecorder()
+	engine.ServeHTTP(w, req)
+	if !strings.Contains(w.Body.String(), `"success":false`) {
+		t.Fatalf("interval update reported success after clock persistence failure: %s", w.Body.String())
+	}
+	if !strings.Contains(w.Body.String(), errInjected.Error()) {
+		t.Fatalf("response omitted clock persistence error: %s", w.Body.String())
+	}
+}

+ 11 - 3
internal/web/job/warp_ip_job.go

@@ -28,13 +28,19 @@ func (j *WarpIpJob) Run() {
 		return
 	}
 
-	lastUpdate, _ := j.settingService.GetWarpLastUpdate()
+	lastUpdate, err := j.settingService.GetWarpLastUpdate()
+	if err != nil {
+		logger.Warning("Failed to read scheduled WARP IP update time: ", err)
+		return
+	}
 	now := time.Now().Unix()
 
 	// First run after the feature is enabled (e.g. interval set via direct
 	// DB edit): establish a baseline instead of rotating immediately.
 	if lastUpdate == 0 {
-		_ = j.settingService.SetWarpLastUpdate(now)
+		if err := j.settingService.SetWarpLastUpdate(now); err != nil {
+			logger.Warning("Failed to establish scheduled WARP IP update time: ", err)
+		}
 		return
 	}
 
@@ -46,7 +52,9 @@ func (j *WarpIpJob) Run() {
 			return
 		}
 
-		_ = j.settingService.SetWarpLastUpdate(now)
+		if err := j.settingService.SetWarpLastUpdate(now); err != nil {
+			logger.Warning("WARP IP changed but the next-update time was not saved: ", err)
+		}
 		j.xrayService.SetToNeedRestart()
 		logger.Info("Successfully updated WARP IP and scheduled Xray restart")
 	}

+ 29 - 0
internal/web/job/warp_ip_job_test.go

@@ -0,0 +1,29 @@
+package job
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+)
+
+func TestWarpIpJobInitializesMissingLastUpdate(t *testing.T) {
+	setupIntegrationDB(t)
+
+	settings := service.SettingService{}
+	if err := settings.SetWarpUpdateInterval(1); err != nil {
+		t.Fatalf("enable scheduled WARP rotation: %v", err)
+	}
+
+	job := &WarpIpJob{settingService: settings}
+	job.Run()
+
+	var stored model.Setting
+	if err := database.GetDB().Where("key = ?", "warpLastUpdate").First(&stored).Error; err != nil {
+		t.Fatalf("scheduled WARP rotation did not establish its baseline: %v", err)
+	}
+	if stored.Value == "" || stored.Value == "0" {
+		t.Fatalf("warpLastUpdate = %q, want a non-zero baseline", stored.Value)
+	}
+}

+ 9 - 3
internal/web/service/setting.go

@@ -391,11 +391,17 @@ func (s *SettingService) setInt(key string, value int) error {
 }
 
 func (s *SettingService) GetWarpLastUpdate() (int64, error) {
-	val, err := s.getString("warpLastUpdate")
-	if err != nil || val == "" {
+	setting, err := s.getSetting("warpLastUpdate")
+	if database.IsNotFound(err) {
+		return 0, nil
+	}
+	if err != nil {
 		return 0, err
 	}
-	return strconv.ParseInt(val, 10, 64)
+	if setting.Value == "" {
+		return 0, nil
+	}
+	return strconv.ParseInt(setting.Value, 10, 64)
 }
 
 func (s *SettingService) SetWarpLastUpdate(val int64) error {

+ 18 - 0
internal/web/service/tgbot/numeric_input.go

@@ -0,0 +1,18 @@
+package tgbot
+
+// updateNumericInput applies one key from the shared inline number pad.
+// Key -2 clears the value, -1 removes the last decimal digit, and 0..9 append
+// a digit. Callers retain their own validation and keyboard labels.
+func updateNumericInput(value, key int) int {
+	switch key {
+	case -2:
+		return 0
+	case -1:
+		if value > 0 {
+			return value / 10
+		}
+		return value
+	default:
+		return value*10 + key
+	}
+}

+ 38 - 0
internal/web/service/tgbot/numeric_input_test.go

@@ -0,0 +1,38 @@
+package tgbot
+
+import (
+	"os"
+	"strings"
+	"testing"
+)
+
+func TestUpdateNumericInput(t *testing.T) {
+	tests := []struct {
+		name       string
+		value, key int
+		want       int
+	}{
+		{name: "append digit", value: 12, key: 3, want: 123},
+		{name: "append zero", value: 12, key: 0, want: 120},
+		{name: "backspace", value: 123, key: -1, want: 12},
+		{name: "backspace zero", value: 0, key: -1, want: 0},
+		{name: "clear", value: 123, key: -2, want: 0},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := updateNumericInput(tt.value, tt.key); got != tt.want {
+				t.Fatalf("updateNumericInput(%d, %d) = %d, want %d", tt.value, tt.key, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestNumericInputTransitionIsUsedByEveryKeypad(t *testing.T) {
+	source, err := os.ReadFile("tgbot_router.go")
+	if err != nil {
+		t.Fatalf("read tgbot_router.go: %v", err)
+	}
+	if got := strings.Count(string(source), "updateNumericInput("); got != 6 {
+		t.Fatalf("numeric keypad transition call sites = %d, want 6", got)
+	}
+}

+ 6 - 60
internal/web/service/tgbot/tgbot_router.go

@@ -439,16 +439,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 						if len(dataArray) == 4 {
 							num, err := strconv.Atoi(dataArray[3])
 							if err == nil {
-								switch num {
-								case -2:
-									inputNumber = 0
-								case -1:
-									if inputNumber > 0 {
-										inputNumber = (inputNumber / 10)
-									}
-								default:
-									inputNumber = (inputNumber * 10) + num
-								}
+								inputNumber = updateNumericInput(inputNumber, num)
 							}
 							if inputNumber == oldInputNumber {
 								t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
@@ -509,16 +500,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 						if len(dataArray) == 3 {
 							num, err := strconv.Atoi(dataArray[2])
 							if err == nil {
-								switch num {
-								case -2:
-									inputNumber = 0
-								case -1:
-									if inputNumber > 0 {
-										inputNumber = (inputNumber / 10)
-									}
-								default:
-									inputNumber = (inputNumber * 10) + num
-								}
+								inputNumber = updateNumericInput(inputNumber, num)
 							}
 							if inputNumber == oldInputNumber {
 								t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
@@ -639,16 +621,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 						if len(dataArray) == 4 {
 							num, err := strconv.Atoi(dataArray[3])
 							if err == nil {
-								switch num {
-								case -2:
-									inputNumber = 0
-								case -1:
-									if inputNumber > 0 {
-										inputNumber = (inputNumber / 10)
-									}
-								default:
-									inputNumber = (inputNumber * 10) + num
-								}
+								inputNumber = updateNumericInput(inputNumber, num)
 							}
 							if inputNumber == oldInputNumber {
 								t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
@@ -721,16 +694,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 						if len(dataArray) == 3 {
 							num, err := strconv.Atoi(dataArray[2])
 							if err == nil {
-								switch num {
-								case -2:
-									inputNumber = 0
-								case -1:
-									if inputNumber > 0 {
-										inputNumber = (inputNumber / 10)
-									}
-								default:
-									inputNumber = (inputNumber * 10) + num
-								}
+								inputNumber = updateNumericInput(inputNumber, num)
 							}
 							if inputNumber == oldInputNumber {
 								t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
@@ -827,16 +791,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 						if len(dataArray) == 4 {
 							num, err := strconv.Atoi(dataArray[3])
 							if err == nil {
-								switch num {
-								case -2:
-									inputNumber = 0
-								case -1:
-									if inputNumber > 0 {
-										inputNumber = (inputNumber / 10)
-									}
-								default:
-									inputNumber = (inputNumber * 10) + num
-								}
+								inputNumber = updateNumericInput(inputNumber, num)
 							}
 							if inputNumber == oldInputNumber {
 								t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
@@ -900,16 +855,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 						if len(dataArray) == 3 {
 							num, err := strconv.Atoi(dataArray[2])
 							if err == nil {
-								switch num {
-								case -2:
-									inputNumber = 0
-								case -1:
-									if inputNumber > 0 {
-										inputNumber = (inputNumber / 10)
-									}
-								default:
-									inputNumber = (inputNumber * 10) + num
-								}
+								inputNumber = updateNumericInput(inputNumber, num)
 							}
 							if inputNumber == oldInputNumber {
 								t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))