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)
|
||||
}
|
||||
|
||||
// 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()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SCAN failed: %v", err)
|
||||
}
|
||||
allKeys = append(allKeys, keys...)
|
||||
cursor = nextCursor
|
||||
if cursor == 0 || len(allKeys) >= maxScan {
|
||||
break
|
||||
}
|
||||
allKeys, err := client.ScanAllKeys(ctx, pattern, limit+offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SCAN failed: %v", err)
|
||||
}
|
||||
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))
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -431,25 +431,26 @@ export default function QueriesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* -- 编辑器 + 侧边栏 (上半区,不随结果滚动) -- */}
|
||||
<div className="shrink-0 px-5 pb-2">
|
||||
<div className="flex gap-3">
|
||||
{/* Schema 侧边栏 */}
|
||||
{showSidebar && instanceId && (
|
||||
<div className="w-[280px] shrink-0">
|
||||
<SchemaSidebar instanceId={instanceId} database={database} dbType={dbType}
|
||||
clusterMode={selectedInstance?.cluster_mode}
|
||||
onInsert={(text) => {
|
||||
const sep = dbType === 'redis' ? (sql.trim() ? '\n' : '') : (sql.trim() ? ' ' : '');
|
||||
setSql(prev => prev + sep + text + (dbType === 'redis' ? '' : ' '));
|
||||
}}
|
||||
onDatabaseChange={setDatabase}
|
||||
singleDb={!!database} />
|
||||
</div>
|
||||
)}
|
||||
{/* -- 编辑器 + 侧边栏 + 结果 (主体区) -- */}
|
||||
<div className="flex-1 min-h-0 flex gap-0 overflow-hidden">
|
||||
{/* Schema 侧边栏(左侧,独立) */}
|
||||
{showSidebar && instanceId && (
|
||||
<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() ? ' ' : '');
|
||||
setSql(prev => prev + sep + text + (dbType === 'redis' ? '' : ' '));
|
||||
}}
|
||||
onDatabaseChange={setDatabase}
|
||||
singleDb={!!database} />
|
||||
</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="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>
|
||||
)}
|
||||
{/* -- 错误提示 -- */}
|
||||
{error && (
|
||||
<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">
|
||||
{/* -- 查询结果 (编辑器下方,独立滚动) -- */}
|
||||
{result && (
|
||||
<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
|
||||
@@ -628,14 +627,16 @@ export default function QueriesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* -- 安全提示 -- */}
|
||||
<div className="shrink-0 px-5 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 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>{/* end 编辑器+结果区 */}
|
||||
</div>{/* end 主体区 (sidebar + 编辑器+结果) */}
|
||||
</div>{/* end 主区域 */}
|
||||
|
||||
{/* ====== 右侧历史抽屉 ====== */}
|
||||
{showHistory && (
|
||||
|
||||
Reference in new issue
Block a user