From e83c9f55536468b011d13215a904a804d6c987cc Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 12 Jun 2026 10:05:01 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(ai):=20=E5=A2=9E=E5=BC=BA=20Re?= =?UTF-8?q?dis=20=E6=8B=93=E6=89=91=E8=AF=8A=E6=96=AD=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加 Redis Sentinel/Cluster 状态分级、阻断原因、后端适配器和脱敏 URI 示例 - 区分配置拓扑与后端实际拓扑,修正多节点单机配置的 ClusterClient 诊断 - 补充 AI 工具目录流程与 Redis 哨兵/集群提示文案 --- .../ai/aiRedisTopologyInsights.test.ts | 37 ++++ .../components/ai/aiRedisTopologyInsights.ts | 175 +++++++++++++++++- .../aiBuiltinInspectionContextToolInfo.ts | 4 +- frontend/src/utils/aiBuiltinToolCatalog.ts | 5 + .../src/utils/connectionTypeCatalog.test.ts | 2 +- frontend/src/utils/connectionTypeCatalog.ts | 2 +- 6 files changed, 211 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/ai/aiRedisTopologyInsights.test.ts b/frontend/src/components/ai/aiRedisTopologyInsights.test.ts index 61cf044..d551285 100644 --- a/frontend/src/components/ai/aiRedisTopologyInsights.test.ts +++ b/frontend/src/components/ai/aiRedisTopologyInsights.test.ts @@ -47,11 +47,26 @@ describe('buildRedisTopologySnapshot', () => { expect(snapshot.totalRedisConnections).toBe(1); expect(snapshot.totalMatched).toBe(1); expect(snapshot.topologyBreakdown).toEqual({ sentinel: 1 }); + expect(snapshot.blockedCount).toBe(1); + expect(snapshot.connections[0].status).toBe('blocked'); + expect(snapshot.connections[0].backendAdapter).toBe('go-redis FailoverClient'); + expect(snapshot.connections[0].effectiveTopology).toBe('sentinel'); + expect(snapshot.connections[0].topologyMismatch).toBe(false); expect(snapshot.connections[0].sentinelMaster).toBe(''); expect(snapshot.connections[0].hasRedisAuth).toBe(true); expect(snapshot.connections[0].hasSentinelAuth).toBe(true); + expect(snapshot.connections[0].safeUriExample).toContain('rediss://app:@sentinel-a.local:6379,sentinel-b.local:26379/2'); + expect(snapshot.connections[0].safeUriExample).toContain('topology=sentinel'); + expect(snapshot.connections[0].safeUriExample).toContain('sentinel_user=sentinel-user'); + expect(snapshot.connections[0].safeUriExample).toContain('sentinel_password=%3Chidden%3E'); + expect(snapshot.connections[0].dbSemantics).toMatchObject({ + physicalDb: 2, + mode: 'failover_selected_db', + }); expect(snapshot.connections[0].warnings).toContain('Sentinel master 名称为空,go-redis FailoverClient 无法发现主节点'); expect(snapshot.connections[0].warnings).toContain('Sentinel 主地址端口是 6379,请确认这里填写的是 Sentinel 端口,常见默认值是 26379'); + expect(snapshot.connections[0].blockingReasons).toContain('Sentinel master 名称为空,go-redis FailoverClient 无法发现主节点'); + expect(snapshot.connections[0].nextActions.join('\n')).toContain('补充 Sentinel master 名称'); expect(JSON.stringify(snapshot)).not.toContain('redis-secret'); expect(JSON.stringify(snapshot)).not.toContain('sentinel-secret'); }); @@ -74,9 +89,20 @@ describe('buildRedisTopologySnapshot', () => { }); expect(snapshot.connections[0].topology).toBe('cluster'); + expect(snapshot.connections[0].effectiveTopology).toBe('cluster'); + expect(snapshot.connections[0].topologyMismatch).toBe(false); + expect(snapshot.connections[0].status).toBe('blocked'); + expect(snapshot.connections[0].backendAdapter).toBe('go-redis ClusterClient'); expect(snapshot.connections[0].seedAddressCount).toBe(3); + expect(snapshot.connections[0].safeUriExample).toBe('redis://10.10.1.10:6379,10.10.1.11:6379,10.10.1.12:6379/0?topology=cluster'); + expect(snapshot.connections[0].dbSemantics).toMatchObject({ + physicalDb: 0, + selectedDb: 4, + mode: 'cluster_logical_namespace', + }); expect(snapshot.connections[0].warnings).toContain('Redis Cluster 当前后端不支持 SSH 隧道,请改用直连、代理或远程 MCP HTTP 方案'); expect(snapshot.connections[0].warnings).toContain('Redis Cluster 物理上只支持 db0;GoNavi 会用 __gonavi_db_N__: 前缀模拟逻辑库隔离'); + expect(snapshot.connections[0].nextActions.join('\n')).toContain('关闭 SSH 隧道'); expect(snapshot.connections[0].recommendations?.join('\n')).toContain('种子节点'); }); @@ -104,6 +130,17 @@ describe('buildRedisTopologySnapshot', () => { expect(snapshot.totalRedisConnections).toBe(1); expect(snapshot.connections).toHaveLength(1); + expect(snapshot.connections[0].status).toBe('needs_attention'); + expect(snapshot.connections[0].topology).toBe('single'); + expect(snapshot.connections[0].effectiveTopology).toBe('cluster'); + expect(snapshot.connections[0].topologyMismatch).toBe(true); + expect(snapshot.connections[0].backendAdapter).toBe('go-redis ClusterClient'); + expect(snapshot.connections[0].safeUriExample).toBe('redis://redis.local:6379,redis-2.local:6379/0?topology=cluster'); + expect(snapshot.connections[0].dbSemantics).toMatchObject({ + physicalDb: 0, + mode: 'cluster_logical_namespace', + }); expect(snapshot.connections[0].warnings).toContain('单机模式下存在多个节点地址,后端会按多节点集群路径处理,建议显式改为 Cluster 模式'); + expect(snapshot.connections[0].nextActions.join('\n')).toContain('显式切换为 Cluster 模式'); }); }); diff --git a/frontend/src/components/ai/aiRedisTopologyInsights.ts b/frontend/src/components/ai/aiRedisTopologyInsights.ts index 6b18d2c..14dccbb 100644 --- a/frontend/src/components/ai/aiRedisTopologyInsights.ts +++ b/frontend/src/components/ai/aiRedisTopologyInsights.ts @@ -1,5 +1,7 @@ import type { SavedConnection } from '../../types'; +type RedisTopology = 'single' | 'cluster' | 'sentinel'; + const normalizeText = (input: unknown): string => String(input || '').trim(); const normalizeLowerText = (input: unknown): string => normalizeText(input).toLowerCase(); @@ -11,13 +13,34 @@ const normalizeLimit = (input: unknown, fallback: number, max: number): number = return value; }; -const normalizeRedisTopology = (input: unknown): 'single' | 'cluster' | 'sentinel' => { +const normalizeRedisTopology = (input: unknown): RedisTopology => { const value = normalizeLowerText(input); if (value === 'cluster') return 'cluster'; if (value === 'sentinel') return 'sentinel'; return 'single'; }; +const resolveEffectiveRedisTopology = ( + configuredTopology: RedisTopology, + seedAddresses: string[], +): RedisTopology => { + if (configuredTopology === 'sentinel') return 'sentinel'; + if (configuredTopology === 'cluster' || seedAddresses.length > 1) return 'cluster'; + return 'single'; +}; + +const topologyAdapterName = (topology: RedisTopology): string => { + if (topology === 'sentinel') return 'go-redis FailoverClient'; + if (topology === 'cluster') return 'go-redis ClusterClient'; + return 'go-redis Client'; +}; + +const topologyModeLabel = (topology: RedisTopology): string => { + if (topology === 'sentinel') return 'Redis Sentinel'; + if (topology === 'cluster') return 'Redis Cluster'; + return 'Redis 单机'; +}; + const buildSeedAddresses = (connection: SavedConnection): string[] => { const config = connection.config || {}; const defaultPort = Number.isFinite(Number(config.port)) ? Number(config.port) : 6379; @@ -49,16 +72,17 @@ const matchesKeyword = (keyword: string, connection: SavedConnection, seedAddres const buildRedisTopologyWarnings = (connection: SavedConnection, seedAddresses: string[]): string[] => { const config = connection.config || {}; - const topology = normalizeRedisTopology(config.topology); + const configuredTopology = normalizeRedisTopology(config.topology); + const effectiveTopology = resolveEffectiveRedisTopology(configuredTopology, seedAddresses); const warnings: string[] = []; if (!normalizeText(config.host)) { warnings.push('主机地址为空,连接前需要填写 Redis 节点或 Sentinel 地址'); } - if ((topology === 'cluster' || topology === 'sentinel') && config.useSSH === true) { - warnings.push(`${topology === 'cluster' ? 'Redis Cluster' : 'Redis Sentinel'} 当前后端不支持 SSH 隧道,请改用直连、代理或远程 MCP HTTP 方案`); + if ((effectiveTopology === 'cluster' || effectiveTopology === 'sentinel') && config.useSSH === true) { + warnings.push(`${effectiveTopology === 'cluster' ? 'Redis Cluster' : 'Redis Sentinel'} 当前后端不支持 SSH 隧道,请改用直连、代理或远程 MCP HTTP 方案`); } - if (topology === 'sentinel') { + if (configuredTopology === 'sentinel') { if (!normalizeText(config.redisSentinelMaster)) { warnings.push('Sentinel master 名称为空,go-redis FailoverClient 无法发现主节点'); } @@ -69,7 +93,7 @@ const buildRedisTopologyWarnings = (connection: SavedConnection, seedAddresses: warnings.push('Sentinel 主地址端口是 6379,请确认这里填写的是 Sentinel 端口,常见默认值是 26379'); } } - if (topology === 'cluster') { + if (effectiveTopology === 'cluster') { if (seedAddresses.length < 2) { warnings.push('Cluster 只配置了一个种子节点,建议填写多个 master/replica 节点提高发现成功率'); } @@ -84,7 +108,7 @@ const buildRedisTopologyWarnings = (connection: SavedConnection, seedAddresses: warnings.push('Cluster 模式下 Sentinel master / Sentinel 用户字段不会生效'); } } - if (topology === 'single') { + if (configuredTopology === 'single') { if (seedAddresses.length > 1) { warnings.push('单机模式下存在多个节点地址,后端会按多节点集群路径处理,建议显式改为 Cluster 模式'); } @@ -96,15 +120,127 @@ const buildRedisTopologyWarnings = (connection: SavedConnection, seedAddresses: return warnings; }; -const buildRedisTopologyRecommendations = (connection: SavedConnection, warnings: string[]): string[] => { +const isBlockingRedisWarning = (warning: string): boolean => + warning.includes('主机地址为空') || + warning.includes('master 名称为空') || + warning.includes('不支持 SSH 隧道'); + +const buildSafeRedisUriExample = ( + connection: SavedConnection, + seedAddresses: string[], +): string => { + const config = connection.config || {}; + const configuredTopology = normalizeRedisTopology(config.topology); + const effectiveTopology = resolveEffectiveRedisTopology(configuredTopology, seedAddresses); + const scheme = config.useSSL === true ? 'rediss' : 'redis'; + const hosts = seedAddresses.length > 0 ? seedAddresses : ['localhost:6379']; + const params = new URLSearchParams(); + if (effectiveTopology === 'sentinel') { + params.set('topology', 'sentinel'); + const masterName = normalizeText(config.redisSentinelMaster); + if (masterName) { + params.set('master', masterName); + } + const sentinelUser = normalizeText(config.redisSentinelUser); + if (sentinelUser) { + params.set('sentinel_user', sentinelUser); + } + if (normalizeText(config.redisSentinelPassword) || connection.hasRedisSentinelPassword === true) { + params.set('sentinel_password', ''); + } + } else if (effectiveTopology === 'cluster') { + params.set('topology', 'cluster'); + } + if (config.useSSL === true) { + const sslMode = normalizeLowerText(config.sslMode || 'preferred'); + if (sslMode === 'skip-verify' || sslMode === 'preferred') { + params.set('skip_verify', 'true'); + } + } + + const redisUser = normalizeText(config.user); + const hasRedisPassword = normalizeText(config.password) || connection.hasPrimaryPassword === true; + const auth = redisUser + ? `${encodeURIComponent(redisUser)}${hasRedisPassword ? ':' : ''}@` + : hasRedisPassword + ? ':@' + : ''; + const redisDB = effectiveTopology === 'cluster' ? 0 : Number(config.redisDB || 0); + const query = params.toString(); + return `${scheme}://${auth}${hosts.join(',')}/${Number.isFinite(redisDB) ? Math.max(0, Math.trunc(redisDB)) : 0}${query ? `?${query}` : ''}`; +}; + +const buildRedisDBSemantics = ( + connection: SavedConnection, + effectiveTopology: RedisTopology, +) => { + const config = connection.config || {}; + const redisDB = typeof config.redisDB === 'number' ? config.redisDB : 0; + const includeRedisDatabases = Array.isArray(connection.includeRedisDatabases) + ? connection.includeRedisDatabases.filter((item) => typeof item === 'number') + : []; + if (effectiveTopology === 'cluster') { + return { + physicalDb: 0, + selectedDb: redisDB, + includeRedisDatabases, + mode: 'cluster_logical_namespace', + note: 'Redis Cluster 物理只支持 db0;GoNavi 用 __gonavi_db_N__: 前缀模拟多库视图。', + }; + } + return { + physicalDb: redisDB, + selectedDb: redisDB, + includeRedisDatabases, + mode: effectiveTopology === 'sentinel' ? 'failover_selected_db' : 'selected_db', + note: effectiveTopology === 'sentinel' + ? 'Sentinel 发现 master 后连接指定 DB,切库会保留 Sentinel 配置重连。' + : '单机模式直接使用 Redis SELECT DB。', + }; +}; + +const buildRedisNextActions = ( + connection: SavedConnection, + warnings: string[], +): string[] => { const config = connection.config || {}; const topology = normalizeRedisTopology(config.topology); + const actions: string[] = []; + if (!normalizeText(config.host)) { + actions.push('先填写主机地址;Sentinel 模式填写 Sentinel 地址,Cluster 模式填写 Redis Cluster 种子节点。'); + } + if (topology === 'sentinel' && !normalizeText(config.redisSentinelMaster)) { + actions.push('补充 Sentinel master 名称,例如 mymaster。'); + } + if (warnings.some((warning) => warning.includes('不支持 SSH 隧道'))) { + actions.push('关闭 SSH 隧道,改用直连、代理/VPN,或使用 GoNavi MCP HTTP 让远端 Agent 通过本机 GoNavi 访问。'); + } + if (warnings.some((warning) => warning.includes('26379'))) { + actions.push('把 Sentinel 主地址端口改为 26379,除非你的 Sentinel 明确监听其他端口。'); + } + if (warnings.some((warning) => warning.includes('db0'))) { + actions.push('确认业务是否真的需要 Redis Cluster 多库视图;如果只是 key 分组,优先使用业务命名空间。'); + } + if (warnings.some((warning) => warning.includes('多节点集群路径'))) { + actions.push('显式切换为 Cluster 模式,或删除附加节点只保留一个单机地址,避免配置拓扑和后端实际拓扑不一致。'); + } + if (actions.length === 0) { + actions.push(`配置看起来可用于 ${topologyModeLabel(topology)},下一步可以测试连接并查看 Redis DB/Key 树。`); + } + return actions; +}; + +const buildRedisTopologyRecommendations = (connection: SavedConnection, warnings: string[]): string[] => { + const config = connection.config || {}; + const configuredTopology = normalizeRedisTopology(config.topology); + const seedAddresses = buildSeedAddresses(connection); + const effectiveTopology = resolveEffectiveRedisTopology(configuredTopology, seedAddresses); const recommendations: string[] = []; - if (topology === 'sentinel') { + if (configuredTopology === 'sentinel') { recommendations.push('确认主机和附加节点填写的是 Sentinel 地址,不是 Redis master 地址'); recommendations.push('分别填写 Redis 数据节点账号密码和 Sentinel 自身账号密码,二者不要混用'); - } else if (topology === 'cluster') { + } else if (effectiveTopology === 'cluster') { recommendations.push('优先配置 2 个以上种子节点,并确认这些节点属于同一个 Redis Cluster'); recommendations.push('如果需要多库视图,优先在业务 key 上显式使用命名空间,避免误解 Cluster 的物理 db0 限制'); } else { @@ -159,14 +295,28 @@ export const buildRedisTopologySnapshot = (params: { const config = connection.config || {}; const topology = normalizeRedisTopology(config.topology); const seedAddresses = buildSeedAddresses(connection); + const effectiveTopology = resolveEffectiveRedisTopology(topology, seedAddresses); const warnings = buildRedisTopologyWarnings(connection, seedAddresses); const includeRedisDatabases = Array.isArray(connection.includeRedisDatabases) ? connection.includeRedisDatabases.filter((item) => typeof item === 'number') : []; + const blockingReasons = warnings.filter(isBlockingRedisWarning); + const status = blockingReasons.length > 0 + ? 'blocked' + : warnings.length > 0 + ? 'needs_attention' + : 'ready'; return { id: connection.id, name: connection.name, topology, + topologyLabel: topologyModeLabel(topology), + effectiveTopology, + effectiveTopologyLabel: topologyModeLabel(effectiveTopology), + topologyMismatch: topology !== effectiveTopology, + status, + blockingReasons, + backendAdapter: topologyAdapterName(effectiveTopology), host: normalizeText(config.host), port: Number.isFinite(Number(config.port)) ? Number(config.port) : null, seedAddresses, @@ -183,7 +333,10 @@ export const buildRedisTopologySnapshot = (params: { hasSentinelAuth: topology === 'sentinel' ? Boolean(normalizeText(config.redisSentinelUser) || normalizeText(config.redisSentinelPassword) || connection.hasRedisSentinelPassword === true) : false, + safeUriExample: buildSafeRedisUriExample(connection, seedAddresses), + dbSemantics: buildRedisDBSemantics(connection, effectiveTopology), warnings, + nextActions: buildRedisNextActions(connection, warnings), recommendations: shouldIncludeRecommendations ? buildRedisTopologyRecommendations(connection, warnings) : undefined, @@ -191,6 +344,7 @@ export const buildRedisTopologySnapshot = (params: { }); const warningCount = visibleConnections.reduce((total, connection) => total + connection.warnings.length, 0); + const blockedCount = visibleConnections.filter((connection) => connection.status === 'blocked').length; return { connectionId: safeConnectionId, @@ -202,6 +356,7 @@ export const buildRedisTopologySnapshot = (params: { truncated: matchedConnections.length > visibleConnections.length, topologyBreakdown, warningCount, + blockedCount, connections: visibleConnections, }; }; diff --git a/frontend/src/utils/aiBuiltinInspectionContextToolInfo.ts b/frontend/src/utils/aiBuiltinInspectionContextToolInfo.ts index 9686c16..7851d3d 100644 --- a/frontend/src/utils/aiBuiltinInspectionContextToolInfo.ts +++ b/frontend/src/utils/aiBuiltinInspectionContextToolInfo.ts @@ -109,14 +109,14 @@ export const BUILTIN_AI_INSPECTION_CONTEXT_TOOL_INFO: AIBuiltinToolInfo[] = [ icon: "🧰", desc: "诊断 Redis 单机/哨兵/集群配置", detail: - "读取本地 Redis 连接拓扑摘要,返回单机、Sentinel、Cluster 的节点、master、认证状态、DB 范围和风险提示。适合用户问 Redis 哨兵/集群怎么配、为什么切库后失败、Cluster 多 DB 怎么处理时先读真实配置。", + "读取本地 Redis 连接拓扑摘要,返回单机、Sentinel、Cluster 的节点、master、认证状态、DB 范围、脱敏 URI 示例、状态分级和下一步动作。适合用户问 Redis 哨兵/集群怎么配、为什么切库后失败、Cluster 多 DB 怎么处理时先读真实配置。", params: "connectionId?, keyword?, limit?, includeRecommendations?(默认 true)", tool: { type: "function", function: { name: "inspect_redis_topology", description: - "读取本地 Redis 连接的单机、Sentinel、Cluster 拓扑配置摘要,返回节点列表、Sentinel master、认证状态、DB 选择、TLS/SSH/代理状态、潜在配置风险和建议。适用于用户提到 Redis 哨兵、Redis Cluster、切换数据库失败、多节点地址、Sentinel master、Cluster 逻辑库或跨网络访问 Redis 时,先读取真实连接配置再回答;结果不会回显 Redis 密码或 Sentinel 密码。", + "读取本地 Redis 连接的单机、Sentinel、Cluster 拓扑配置摘要,返回节点列表、Sentinel master、认证状态、DB 选择、TLS/SSH/代理状态、后端适配器、脱敏 URI 示例、状态分级、阻断原因、潜在配置风险和建议。适用于用户提到 Redis 哨兵、Redis Cluster、切换数据库失败、多节点地址、Sentinel master、Cluster 逻辑库或跨网络访问 Redis 时,先读取真实连接配置再回答;结果不会回显 Redis 密码或 Sentinel 密码。", parameters: { type: "object", properties: { diff --git a/frontend/src/utils/aiBuiltinToolCatalog.ts b/frontend/src/utils/aiBuiltinToolCatalog.ts index 436d9c1..3ae1859 100644 --- a/frontend/src/utils/aiBuiltinToolCatalog.ts +++ b/frontend/src/utils/aiBuiltinToolCatalog.ts @@ -137,6 +137,11 @@ export const BUILTIN_TOOL_FLOWS: AIBuiltinToolFlow[] = [ steps: 'inspect_saved_connections -> inspect_current_connection / get_databases', description: '适合先按关键词或类型筛出本地保存的数据源,再挑目标连接继续看当前状态或库表结构。', }, + { + title: '诊断 Redis 拓扑', + steps: 'inspect_redis_topology -> inspect_current_connection / inspect_app_logs', + description: '适合用户问 Redis 哨兵、Cluster、多节点、切库失败或 SSH 隧道不可用时,先拿到状态分级、脱敏 URI、后端适配器、DB 语义和下一步动作。', + }, { title: '盘点外部 SQL 目录', steps: 'inspect_external_sql_directories -> inspect_workspace_tabs / inspect_active_tab', diff --git a/frontend/src/utils/connectionTypeCatalog.test.ts b/frontend/src/utils/connectionTypeCatalog.test.ts index 6e3e1f1..c6909d0 100644 --- a/frontend/src/utils/connectionTypeCatalog.test.ts +++ b/frontend/src/utils/connectionTypeCatalog.test.ts @@ -43,7 +43,7 @@ describe('connectionTypeCatalog', () => { }); it('keeps concise localized hints for special connection types', () => { - expect(getConnectionTypeHint('redis')).toBe('单机 / 集群'); + expect(getConnectionTypeHint('redis')).toBe('单机 / 哨兵 / 集群'); expect(getConnectionTypeHint('mongodb')).toBe('单机 / 副本集'); expect(getConnectionTypeHint('elasticsearch')).toContain('Mapping'); expect(getConnectionTypeHint('oceanbase')).toBe('MySQL / Oracle 租户'); diff --git a/frontend/src/utils/connectionTypeCatalog.ts b/frontend/src/utils/connectionTypeCatalog.ts index de8e023..c900c28 100644 --- a/frontend/src/utils/connectionTypeCatalog.ts +++ b/frontend/src/utils/connectionTypeCatalog.ts @@ -118,7 +118,7 @@ export const getConnectionTypeHint = (type: string): string => { case 'custom': return '自定义驱动与 DSN'; case 'redis': - return '单机 / 集群'; + return '单机 / 哨兵 / 集群'; case 'mongodb': return '单机 / 副本集'; case 'elasticsearch':