3 Commits c77608bc47 ... b319dd0c3a

Author SHA1 Message Date
  Sanaei b319dd0c3a fix(panel): align telegram icon with its label in home card actions 7 hours ago
  Sanaei 8ef2eec3d1 fix(hosts): assign group ids to imported hosts and repair empty ones 8 hours ago
  Sanaei 941c6116a9 chore(openapi): regenerate schemas with int64 formats on node fields 11 hours ago

+ 7 - 0
frontend/public/openapi.json

@@ -2385,6 +2385,7 @@
           },
           },
           "configDirtyAt": {
           "configDirtyAt": {
             "example": 0,
             "example": 0,
+            "format": "int64",
             "type": "integer"
             "type": "integer"
           },
           },
           "cpuPct": {
           "cpuPct": {
@@ -2393,6 +2394,7 @@
           },
           },
           "createdAt": {
           "createdAt": {
             "example": 1700000000,
             "example": 1700000000,
+            "format": "int64",
             "type": "integer"
             "type": "integer"
           },
           },
           "depletedCount": {
           "depletedCount": {
@@ -2441,6 +2443,7 @@
           },
           },
           "lastHeartbeat": {
           "lastHeartbeat": {
             "example": 1700000000,
             "example": 1700000000,
+            "format": "int64",
             "type": "integer"
             "type": "integer"
           },
           },
           "latencyMs": {
           "latencyMs": {
@@ -2457,10 +2460,12 @@
           },
           },
           "netDown": {
           "netDown": {
             "example": 1048576,
             "example": 1048576,
+            "format": "int64",
             "type": "integer"
             "type": "integer"
           },
           },
           "netUp": {
           "netUp": {
             "example": 2097152,
             "example": 2097152,
+            "format": "int64",
             "type": "integer"
             "type": "integer"
           },
           },
           "onlineCount": {
           "onlineCount": {
@@ -2507,10 +2512,12 @@
           },
           },
           "updatedAt": {
           "updatedAt": {
             "example": 1700003600,
             "example": 1700003600,
+            "format": "int64",
             "type": "integer"
             "type": "integer"
           },
           },
           "uptimeSecs": {
           "uptimeSecs": {
             "example": 86400,
             "example": 86400,
+            "format": "int64",
             "type": "integer"
             "type": "integer"
           },
           },
           "xrayError": {
           "xrayError": {

+ 7 - 0
frontend/src/generated/schemas.ts

@@ -2359,6 +2359,7 @@ export const SCHEMAS: Record<string, unknown> = {
       },
       },
       "configDirtyAt": {
       "configDirtyAt": {
         "example": 0,
         "example": 0,
+        "format": "int64",
         "type": "integer"
         "type": "integer"
       },
       },
       "cpuPct": {
       "cpuPct": {
@@ -2367,6 +2368,7 @@ export const SCHEMAS: Record<string, unknown> = {
       },
       },
       "createdAt": {
       "createdAt": {
         "example": 1700000000,
         "example": 1700000000,
+        "format": "int64",
         "type": "integer"
         "type": "integer"
       },
       },
       "depletedCount": {
       "depletedCount": {
@@ -2415,6 +2417,7 @@ export const SCHEMAS: Record<string, unknown> = {
       },
       },
       "lastHeartbeat": {
       "lastHeartbeat": {
         "example": 1700000000,
         "example": 1700000000,
+        "format": "int64",
         "type": "integer"
         "type": "integer"
       },
       },
       "latencyMs": {
       "latencyMs": {
@@ -2431,10 +2434,12 @@ export const SCHEMAS: Record<string, unknown> = {
       },
       },
       "netDown": {
       "netDown": {
         "example": 1048576,
         "example": 1048576,
+        "format": "int64",
         "type": "integer"
         "type": "integer"
       },
       },
       "netUp": {
       "netUp": {
         "example": 2097152,
         "example": 2097152,
+        "format": "int64",
         "type": "integer"
         "type": "integer"
       },
       },
       "onlineCount": {
       "onlineCount": {
@@ -2481,10 +2486,12 @@ export const SCHEMAS: Record<string, unknown> = {
       },
       },
       "updatedAt": {
       "updatedAt": {
         "example": 1700003600,
         "example": 1700003600,
+        "format": "int64",
         "type": "integer"
         "type": "integer"
       },
       },
       "uptimeSecs": {
       "uptimeSecs": {
         "example": 86400,
         "example": 86400,
+        "format": "int64",
         "type": "integer"
         "type": "integer"
       },
       },
       "xrayError": {
       "xrayError": {

+ 1 - 6
frontend/src/pages/index/IndexPage.css

@@ -12,7 +12,7 @@
   flex-wrap: nowrap;
   flex-wrap: nowrap;
 }
 }
 
 
-.index-page .action > span:not(.anticon):not(.tg-icon) {
+.index-page .action > span:not(.anticon) {
   white-space: nowrap;
   white-space: nowrap;
   overflow: hidden;
   overflow: hidden;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
@@ -36,11 +36,6 @@
   margin-inline-end: 0;
   margin-inline-end: 0;
 }
 }
 
 
-.index-page .tg-icon {
-  display: inline-block;
-  vertical-align: -2px;
-}
-
 .index-page .ip-toggle-icon {
 .index-page .ip-toggle-icon {
   cursor: pointer;
   cursor: pointer;
   font-size: 16px;
   font-size: 16px;

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

@@ -246,7 +246,7 @@ export default function IndexPage() {
                       hoverable
                       hoverable
                       actions={[
                       actions={[
                         <Space className="action" key="tg" role="button" tabIndex={0} aria-label="@XrayUI" onClick={openTelegram} onKeyDown={activateOnKey(openTelegram)}>
                         <Space className="action" key="tg" role="button" tabIndex={0} aria-label="@XrayUI" onClick={openTelegram} onKeyDown={activateOnKey(openTelegram)}>
-                          <TelegramFilled className="tg-icon" aria-hidden="true" />
+                          <TelegramFilled aria-hidden="true" />
                           {!isMobile && <span>@XrayUI</span>}
                           {!isMobile && <span>@XrayUI</span>}
                         </Space>,
                         </Space>,
                         <Space
                         <Space

+ 17 - 25
internal/database/db.go

@@ -686,6 +686,7 @@ func externalProxyEntryToHost(inboundId, index int, ep map[string]any) *model.Ho
 	fingerprint, _ := ep["fingerprint"].(string)
 	fingerprint, _ := ep["fingerprint"].(string)
 	ech, _ := ep["echConfigList"].(string)
 	ech, _ := ep["echConfigList"].(string)
 	return &model.Host{
 	return &model.Host{
+		GroupId:              random.NumLower(16),
 		InboundId:            inboundId,
 		InboundId:            inboundId,
 		SortOrder:            index,
 		SortOrder:            index,
 		Remark:               remark,
 		Remark:               remark,
@@ -1180,7 +1181,7 @@ func runSeeders(isUsersEmpty bool) error {
 		return err
 		return err
 	}
 	}
 
 
-	if err := seedHostGroupIds(); err != nil {
+	if err := backfillEmptyHostGroupIds(); err != nil {
 		return err
 		return err
 	}
 	}
 
 
@@ -1213,36 +1214,27 @@ func seedNodeInboundsAdopted() error {
 	return db.Create(&model.HistoryOfSeeders{SeederName: "NodeInboundsAdopted"}).Error
 	return db.Create(&model.HistoryOfSeeders{SeederName: "NodeInboundsAdopted"}).Error
 }
 }
 
 
-func seedHostGroupIds() error {
-	var history []string
-	if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
-		return err
-	}
-	if slices.Contains(history, "HostGroupIds") {
-		return nil
-	}
-
+// backfillEmptyHostGroupIds is idempotent and not seeder-gated: builds that
+// predate group ids on the inbound-import path (and restored backups) can
+// re-introduce hosts rows with an empty group_id, and such rows render as a
+// synthetic fallback_<id> group the update/delete API cannot address, so
+// re-check on every start.
+func backfillEmptyHostGroupIds() error {
 	var hosts []*model.Host
 	var hosts []*model.Host
 	if err := db.Where("group_id = '' OR group_id IS NULL").Find(&hosts).Error; err != nil {
 	if err := db.Where("group_id = '' OR group_id IS NULL").Find(&hosts).Error; err != nil {
 		return err
 		return err
 	}
 	}
-
-	if len(hosts) > 0 {
-		err := db.Transaction(func(tx *gorm.DB) error {
-			for _, h := range hosts {
-				h.GroupId = random.NumLower(16)
-				if err := tx.Model(h).Update("group_id", h.GroupId).Error; err != nil {
-					return err
-				}
+	if len(hosts) == 0 {
+		return nil
+	}
+	return db.Transaction(func(tx *gorm.DB) error {
+		for _, h := range hosts {
+			if err := tx.Model(h).Update("group_id", random.NumLower(16)).Error; err != nil {
+				return err
 			}
 			}
-			return nil
-		})
-		if err != nil {
-			return err
 		}
 		}
-	}
-
-	return db.Create(&model.HistoryOfSeeders{SeederName: "HostGroupIds"}).Error
+		return nil
+	})
 }
 }
 
 
 func resetIpLimitsWithoutFail2ban() error {
 func resetIpLimitsWithoutFail2ban() error {

+ 41 - 0
internal/database/host_migration_test.go

@@ -67,6 +67,47 @@ func TestMigrate_ExternalProxyToHosts(t *testing.T) {
 		b.Port != 80 || b.Remark != "B" {
 		b.Port != 80 || b.Remark != "B" {
 		t.Fatalf("host B mapping wrong: %+v", b)
 		t.Fatalf("host B mapping wrong: %+v", b)
 	}
 	}
+	if a.GroupId == "" || b.GroupId == "" {
+		t.Fatalf("group ids must be assigned at creation: a=%q b=%q", a.GroupId, b.GroupId)
+	}
+	if a.GroupId == b.GroupId {
+		t.Fatalf("each entry must get its own group id, both = %q", a.GroupId)
+	}
+}
+
+// #1b — a hosts row that entered the DB without a group_id (older-build import
+// or restored backup) is repaired on every start, so it stays addressable by
+// the group-scoped update/delete API instead of surfacing as fallback_<id>.
+func TestBackfillEmptyHostGroupIds_RepairsLegacyRows(t *testing.T) {
+	initMigrateDB(t)
+	ib := seedInboundWithStream(t, "m1b", 5556, `{"network":"tcp","security":"none"}`)
+	legacy := &model.Host{InboundId: ib.Id, Remark: "legacy", Address: "c.cdn.com", Port: 443, Security: "tls"}
+	if err := GetDB().Create(legacy).Error; err != nil {
+		t.Fatalf("create legacy host: %v", err)
+	}
+
+	if err := backfillEmptyHostGroupIds(); err != nil {
+		t.Fatalf("backfill: %v", err)
+	}
+
+	var got model.Host
+	if err := GetDB().First(&got, legacy.Id).Error; err != nil {
+		t.Fatalf("reload host: %v", err)
+	}
+	if got.GroupId == "" {
+		t.Fatal("group_id still empty after backfill")
+	}
+
+	if err := backfillEmptyHostGroupIds(); err != nil {
+		t.Fatalf("second backfill: %v", err)
+	}
+	var again model.Host
+	if err := GetDB().First(&again, legacy.Id).Error; err != nil {
+		t.Fatalf("reload host after second run: %v", err)
+	}
+	if again.GroupId != got.GroupId {
+		t.Fatalf("second run must not touch repaired rows: %q -> %q", got.GroupId, again.GroupId)
+	}
 }
 }
 
 
 // #2 — a second run is a no-op (the HistoryOfSeeders gate).
 // #2 — a second run is a no-op (the HistoryOfSeeders gate).

+ 3 - 3
internal/web/service/host.go

@@ -213,11 +213,11 @@ func (s *HostService) GetHostGroup(groupId string) (*entity.HostGroup, error) {
 		return nil, err
 		return nil, err
 	}
 	}
 	if len(hosts) == 0 {
 	if len(hosts) == 0 {
-		return nil, common.NewError("host group not found")
+		return nil, common.NewError("host not found")
 	}
 	}
 	grouped := groupHosts(hosts)
 	grouped := groupHosts(hosts)
 	if len(grouped) == 0 {
 	if len(grouped) == 0 {
-		return nil, common.NewError("host group not found")
+		return nil, common.NewError("host not found")
 	}
 	}
 	return grouped[0], nil
 	return grouped[0], nil
 }
 }
@@ -253,7 +253,7 @@ func (s *HostService) UpdateHostGroup(groupId string, req *entity.HostGroup) ([]
 			return err
 			return err
 		}
 		}
 		if count == 0 {
 		if count == 0 {
-			return common.NewError("host group not found")
+			return common.NewError("host not found")
 		}
 		}
 		if err := validateInboundsExist(tx, req.InboundIds); err != nil {
 		if err := validateInboundsExist(tx, req.InboundIds); err != nil {
 			return err
 			return err