fix: Redis集群SCAN遍历所有master节点 + 查询结果布局调整\n\n1. ScanAllKeys(): 集群模式用ForEachMaster遍历所有master节点SCAN合并去重\n2. RedisScanKeys/KEYS命令均改用ScanAllKeys,确保集群不遗漏key\n3. QueriesPage布局: sidebar独立左侧,查询结果只在编辑器下方不被sidebar占据宽度

This commit is contained in:
logan committed 2026-05-30 15:28:38 +08:00
1 parent 430a667455
commit 4e6f9eb8fd
3 files changed
+71 -35

No files matched your search

+6 -16
View File
@@ -749,7 +749,7 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string
args = append(args, p)
}
// For KEYS command, use SCAN with pagination (auto-append * if no wildcard)
// For KEYS command, use ScanAllKeys (cluster-aware) with pagination
if cmd == "KEYS" {
pattern := "*"
if len(parts) > 1 {
@@ -759,27 +759,17 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string
if !strings.Contains(pattern, "*") && !strings.Contains(pattern, "?") && !strings.Contains(pattern, "[") {
pattern = pattern + "*"
}
var allKeys []string
cursor := uint64(0)
maxScan := offset + limit
for {
keys, nextCursor, err := client.Scan(ctx, cursor, pattern, int64(maxScan-len(allKeys))).Result()
allKeys, err := client.ScanAllKeys(ctx, pattern, limit+offset)
if err != nil {
return nil, fmt.Errorf("SCAN failed: %v", err)
}
allKeys = append(allKeys, keys...)
cursor = nextCursor
if cursor == 0 || len(allKeys) >= maxScan {
break
}
}
total := len(allKeys)
end := offset + limit
if end > len(allKeys) {
end = len(allKeys)
if end > total {
end = total
}
if offset > len(allKeys) {
offset = len(allKeys)
if offset > total {
offset = total
}
pagedKeys := allKeys[offset:end]
rows := make([]interface{}, len(pagedKeys))
+50 -5
View File
@@ -59,6 +59,53 @@ func (w *redisClientWrapper) Scan(ctx context.Context, cursor uint64, match stri
return w.Client.Scan(ctx, cursor, match, count)
}
// ScanAllKeys scans keys across ALL master nodes in cluster mode, or single node in standalone.
// Returns merged deduplicated keys.
func (w *redisClientWrapper) ScanAllKeys(ctx context.Context, match string, count int) ([]string, error) {
if !w.isCluster {
// Standalone: iterate SCAN until cursor returns to 0
var allKeys []string
cursor := uint64(0)
for {
keys, nextCursor, err := w.Client.Scan(ctx, cursor, match, int64(count)).Result()
if err != nil {
return allKeys, err
}
allKeys = append(allKeys, keys...)
cursor = nextCursor
if cursor == 0 {
break
}
}
return allKeys, nil
}
// Cluster: iterate SCAN on every master node
var allKeys []string
seen := make(map[string]bool)
err := w.ClusterClient.ForEachMaster(ctx, func(ctx context.Context, node *redis.Client) error {
cursor := uint64(0)
for {
keys, nextCursor, err := node.Scan(ctx, cursor, match, int64(count)).Result()
if err != nil {
return err
}
for _, k := range keys {
if !seen[k] {
seen[k] = true
allKeys = append(allKeys, k)
}
}
cursor = nextCursor
if cursor == 0 {
break
}
}
return nil
})
return allKeys, err
}
func (w *redisClientWrapper) Type(ctx context.Context, key string) *redis.StatusCmd {
if w.isCluster {
return w.ClusterClient.Type(ctx, key)
@@ -321,7 +368,6 @@ func RedisScanKeys(c *gin.Context) {
pattern = pattern + "*"
}
count, _ := strconv.Atoi(c.DefaultQuery("count", "100"))
cursorParam := c.DefaultQuery("cursor", "0")
db := 0
if dbParam != "" {
@@ -342,8 +388,7 @@ func RedisScanKeys(c *gin.Context) {
defer client.Close()
ctx := context.Background()
cursor, _ := strconv.ParseUint(cursorParam, 10, 64)
keys, nextCursor, err := client.Scan(ctx, cursor, pattern, int64(count)).Result()
allKeys, err := client.ScanAllKeys(ctx, pattern, count)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
@@ -356,7 +401,7 @@ func RedisScanKeys(c *gin.Context) {
TTL int64 `json:"ttl"`
}
var keyInfos []KeyInfo
for _, key := range keys {
for _, key := range allKeys {
keyType, _ := client.Type(ctx, key).Result()
ttl, _ := client.TTL(ctx, key).Result()
ttlMs := int64(-1)
@@ -371,7 +416,7 @@ func RedisScanKeys(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"keys": keyInfos,
"cursor": nextCursor,
"cursor": 0,
"total": len(keyInfos),
})
}
+15 -14
View File
@@ -431,16 +431,15 @@ export default function QueriesPage() {
</div>
)}
{/* -- 编辑器 + 侧边栏 (上半区,不随结果滚动) -- */}
<div className="shrink-0 px-5 pb-2">
<div className="flex gap-3">
{/* Schema 侧边栏 */}
{/* -- 编辑器 + 侧边栏 + 结果 (主体区) -- */}
<div className="flex-1 min-h-0 flex gap-0 overflow-hidden">
{/* Schema 侧边栏(左侧,独立) */}
{showSidebar && instanceId && (
<div className="w-[280px] shrink-0">
<div className="w-[280px] shrink-0 border-r border-[var(--dg-border)] overflow-y-auto">
<SchemaSidebar instanceId={instanceId} database={database} dbType={dbType}
clusterMode={selectedInstance?.cluster_mode}
onInsert={(text) => {
const sep = dbType === 'redis' ? (sql.trim() ? '\n' : '') : (sql.trim() ? ' ' : '');
const sep = dbType === 'redis' ? (sql.trim() ? '\\n' : '') : (sql.trim() ? ' ' : '');
setSql(prev => prev + sep + text + (dbType === 'redis' ? '' : ' '));
}}
onDatabaseChange={setDatabase}
@@ -448,8 +447,10 @@ export default function QueriesPage() {
</div>
)}
{/* 编辑器 + 查询结果(右侧主体,垂直排列) */}
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
{/* SQL 编辑器 */}
<div className="flex-1 min-w-0 flex flex-col">
<div className="shrink-0 p-4 pb-2">
<div className="dg-card overflow-hidden flex flex-col">
<SqlEditor value={sql} onChange={setSql} placeholder={placeholderMap[dbType]}
language={editorLang} height="200px" onExecute={handleExecute}
@@ -466,21 +467,19 @@ export default function QueriesPage() {
</div>
</div>
</div>
</div>
</div>
{/* -- 错误提示 -- */}
{error && (
<div className="shrink-0 px-5 pb-2">
<div className="shrink-0 px-4 pb-2">
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400">
<AlertTriangle className="w-4 h-4 shrink-0" />{error}
</div>
</div>
)}
{/* -- 查询结果 (下半区,独立滚动) -- */}
{/* -- 查询结果 (编辑器下方,独立滚动) -- */}
{result && (
<div className="flex-1 min-h-0 px-5 pb-4 overflow-hidden flex flex-col">
<div className="flex-1 min-h-0 px-4 pb-4 overflow-hidden flex flex-col">
<div className="dg-card overflow-hidden flex-1 flex flex-col">
{/* 结果标题栏 + 折叠按钮 */}
<div
@@ -629,13 +628,15 @@ export default function QueriesPage() {
)}
{/* -- 安全提示 -- */}
<div className="shrink-0 px-5 pb-3">
<div className="shrink-0 px-4 pb-3">
<div className="flex items-center gap-2 px-3 py-2 bg-yellow-500/5 border border-yellow-500/15 rounded-lg text-xs text-[var(--dg-text-muted)]">
<AlertTriangle className="w-3.5 h-3.5 text-yellow-400 shrink-0" />
查询结果中的敏感字段将自动脱敏。MySQL/PG 仅支持 SELECT 查询,Redis/MongoDB 支持只读命令。
</div>
</div>
</div>
</div>{/* end 编辑器+结果区 */}
</div>{/* end 主体区 (sidebar + 编辑器+结果) */}
</div>{/* end 主区域 */}
{/* ====== 右侧历史抽屉 ====== */}
{showHistory && (