fix: Redis集群SCAN遍历所有master节点 + 查询结果布局调整\n\n1. ScanAllKeys(): 集群模式用ForEachMaster遍历所有master节点SCAN合并去重\n2. RedisScanKeys/KEYS命令均改用ScanAllKeys,确保集群不遗漏key\n3. QueriesPage布局: sidebar独立左侧,查询结果只在编辑器下方不被sidebar占据宽度
This commit is contained in:
1 parent
430a667455
commit
4e6f9eb8fd
3 files changed
+97
-61
No files matched your search
@@ -749,7 +749,7 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string
|
|||||||
args = append(args, p)
|
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" {
|
if cmd == "KEYS" {
|
||||||
pattern := "*"
|
pattern := "*"
|
||||||
if len(parts) > 1 {
|
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, "[") {
|
if !strings.Contains(pattern, "*") && !strings.Contains(pattern, "?") && !strings.Contains(pattern, "[") {
|
||||||
pattern = pattern + "*"
|
pattern = pattern + "*"
|
||||||
}
|
}
|
||||||
var allKeys []string
|
allKeys, err := client.ScanAllKeys(ctx, pattern, limit+offset)
|
||||||
cursor := uint64(0)
|
if err != nil {
|
||||||
maxScan := offset + limit
|
return nil, fmt.Errorf("SCAN failed: %v", err)
|
||||||
for {
|
|
||||||
keys, nextCursor, err := client.Scan(ctx, cursor, pattern, int64(maxScan-len(allKeys))).Result()
|
|
||||||
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)
|
total := len(allKeys)
|
||||||
end := offset + limit
|
end := offset + limit
|
||||||
if end > len(allKeys) {
|
if end > total {
|
||||||
end = len(allKeys)
|
end = total
|
||||||
}
|
}
|
||||||
if offset > len(allKeys) {
|
if offset > total {
|
||||||
offset = len(allKeys)
|
offset = total
|
||||||
}
|
}
|
||||||
pagedKeys := allKeys[offset:end]
|
pagedKeys := allKeys[offset:end]
|
||||||
rows := make([]interface{}, len(pagedKeys))
|
rows := make([]interface{}, len(pagedKeys))
|
||||||
|
|||||||
@@ -59,6 +59,53 @@ func (w *redisClientWrapper) Scan(ctx context.Context, cursor uint64, match stri
|
|||||||
return w.Client.Scan(ctx, cursor, match, count)
|
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 {
|
func (w *redisClientWrapper) Type(ctx context.Context, key string) *redis.StatusCmd {
|
||||||
if w.isCluster {
|
if w.isCluster {
|
||||||
return w.ClusterClient.Type(ctx, key)
|
return w.ClusterClient.Type(ctx, key)
|
||||||
@@ -321,7 +368,6 @@ func RedisScanKeys(c *gin.Context) {
|
|||||||
pattern = pattern + "*"
|
pattern = pattern + "*"
|
||||||
}
|
}
|
||||||
count, _ := strconv.Atoi(c.DefaultQuery("count", "100"))
|
count, _ := strconv.Atoi(c.DefaultQuery("count", "100"))
|
||||||
cursorParam := c.DefaultQuery("cursor", "0")
|
|
||||||
|
|
||||||
db := 0
|
db := 0
|
||||||
if dbParam != "" {
|
if dbParam != "" {
|
||||||
@@ -342,8 +388,7 @@ func RedisScanKeys(c *gin.Context) {
|
|||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
cursor, _ := strconv.ParseUint(cursorParam, 10, 64)
|
allKeys, err := client.ScanAllKeys(ctx, pattern, count)
|
||||||
keys, nextCursor, err := client.Scan(ctx, cursor, pattern, int64(count)).Result()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -356,7 +401,7 @@ func RedisScanKeys(c *gin.Context) {
|
|||||||
TTL int64 `json:"ttl"`
|
TTL int64 `json:"ttl"`
|
||||||
}
|
}
|
||||||
var keyInfos []KeyInfo
|
var keyInfos []KeyInfo
|
||||||
for _, key := range keys {
|
for _, key := range allKeys {
|
||||||
keyType, _ := client.Type(ctx, key).Result()
|
keyType, _ := client.Type(ctx, key).Result()
|
||||||
ttl, _ := client.TTL(ctx, key).Result()
|
ttl, _ := client.TTL(ctx, key).Result()
|
||||||
ttlMs := int64(-1)
|
ttlMs := int64(-1)
|
||||||
@@ -371,7 +416,7 @@ func RedisScanKeys(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"keys": keyInfos,
|
"keys": keyInfos,
|
||||||
"cursor": nextCursor,
|
"cursor": 0,
|
||||||
"total": len(keyInfos),
|
"total": len(keyInfos),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -431,25 +431,26 @@ export default function QueriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* -- 编辑器 + 侧边栏 (上半区,不随结果滚动) -- */}
|
{/* -- 编辑器 + 侧边栏 + 结果 (主体区) -- */}
|
||||||
<div className="shrink-0 px-5 pb-2">
|
<div className="flex-1 min-h-0 flex gap-0 overflow-hidden">
|
||||||
<div className="flex gap-3">
|
{/* Schema 侧边栏(左侧,独立) */}
|
||||||
{/* Schema 侧边栏 */}
|
{showSidebar && instanceId && (
|
||||||
{showSidebar && instanceId && (
|
<div className="w-[280px] shrink-0 border-r border-[var(--dg-border)] overflow-y-auto">
|
||||||
<div className="w-[280px] shrink-0">
|
<SchemaSidebar instanceId={instanceId} database={database} dbType={dbType}
|
||||||
<SchemaSidebar instanceId={instanceId} database={database} dbType={dbType}
|
clusterMode={selectedInstance?.cluster_mode}
|
||||||
clusterMode={selectedInstance?.cluster_mode}
|
onInsert={(text) => {
|
||||||
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' ? '' : ' '));
|
||||||
setSql(prev => prev + sep + text + (dbType === 'redis' ? '' : ' '));
|
}}
|
||||||
}}
|
onDatabaseChange={setDatabase}
|
||||||
onDatabaseChange={setDatabase}
|
singleDb={!!database} />
|
||||||
singleDb={!!database} />
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
|
{/* 编辑器 + 查询结果(右侧主体,垂直排列) */}
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||||||
{/* SQL 编辑器 */}
|
{/* 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">
|
<div className="dg-card overflow-hidden flex flex-col">
|
||||||
<SqlEditor value={sql} onChange={setSql} placeholder={placeholderMap[dbType]}
|
<SqlEditor value={sql} onChange={setSql} placeholder={placeholderMap[dbType]}
|
||||||
language={editorLang} height="200px" onExecute={handleExecute}
|
language={editorLang} height="200px" onExecute={handleExecute}
|
||||||
@@ -466,21 +467,19 @@ export default function QueriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* -- 错误提示 -- */}
|
{/* -- 错误提示 -- */}
|
||||||
{error && (
|
{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">
|
<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}
|
<AlertTriangle className="w-4 h-4 shrink-0" />{error}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* -- 查询结果 (下半区,独立滚动) -- */}
|
{/* -- 查询结果 (编辑器下方,独立滚动) -- */}
|
||||||
{result && (
|
{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 className="dg-card overflow-hidden flex-1 flex flex-col">
|
||||||
{/* 结果标题栏 + 折叠按钮 */}
|
{/* 结果标题栏 + 折叠按钮 */}
|
||||||
<div
|
<div
|
||||||
@@ -628,14 +627,16 @@ export default function QueriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* -- 安全提示 -- */}
|
{/* -- 安全提示 -- */}
|
||||||
<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)]">
|
<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" />
|
<AlertTriangle className="w-3.5 h-3.5 text-yellow-400 shrink-0" />
|
||||||
查询结果中的敏感字段将自动脱敏。MySQL/PG 仅支持 SELECT 查询,Redis/MongoDB 支持只读命令。
|
查询结果中的敏感字段将自动脱敏。MySQL/PG 仅支持 SELECT 查询,Redis/MongoDB 支持只读命令。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>{/* end 编辑器+结果区 */}
|
||||||
|
</div>{/* end 主体区 (sidebar + 编辑器+结果) */}
|
||||||
|
</div>{/* end 主区域 */}
|
||||||
|
|
||||||
{/* ====== 右侧历史抽屉 ====== */}
|
{/* ====== 右侧历史抽屉 ====== */}
|
||||||
{showHistory && (
|
{showHistory && (
|
||||||
|
|||||||
Reference in new issue
Block a user