diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx index c9df203..dda8f9d 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx @@ -73,6 +73,9 @@ describe('AIBuiltinToolsCatalog', () => { expect(markup).toContain('inspect_connection_capabilities'); expect(markup).toContain('盘点本地连接资产'); expect(markup).toContain('inspect_saved_connections'); + expect(markup).toContain('诊断 Redis 单机/哨兵/集群配置'); + expect(markup).toContain('inspect_redis_topology'); + expect(markup).toContain('Sentinel master'); expect(markup).toContain('盘点外部 SQL 目录'); expect(markup).toContain('inspect_external_sql_directories'); expect(markup).toContain('读取外部 SQL 文件'); diff --git a/frontend/src/components/ai/aiLocalToolExecutor.connectionInspection.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.connectionInspection.test.ts index 6ec3b93..928d211 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.connectionInspection.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.connectionInspection.test.ts @@ -162,4 +162,58 @@ describe('aiLocalToolExecutor connection inspection tools', () => { expect(result.content).toContain('"useSSH":true'); expect(result.content).not.toContain('分析仓库'); }); + + it('returns a Redis topology snapshot with Sentinel and Cluster risks', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_redis_topology', { + keyword: '订单', + }), + connections: [ + { + id: 'redis-sentinel', + name: '订单 Redis Sentinel', + config: { + type: 'redis', + host: 'sentinel-a.local', + port: 6379, + hosts: ['sentinel-b.local:26379'], + topology: 'sentinel', + user: 'app', + password: 'redis-secret', + redisSentinelPassword: 'sentinel-secret', + }, + hasRedisSentinelPassword: true, + }, + { + id: 'redis-cluster', + name: '缓存集群', + config: { + type: 'redis', + host: '10.10.1.10', + port: 6379, + user: '', + topology: 'cluster', + hosts: ['10.10.1.11:6379'], + redisDB: 3, + useSSH: true, + }, + }, + ], + mcpTools: [], + toolContextMap: new Map(), + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain('"totalRedisConnections":2'); + expect(result.content).toContain('"totalMatched":1'); + expect(result.content).toContain('"topology":"sentinel"'); + expect(result.content).toContain('Sentinel master 名称为空'); + expect(result.content).toContain('Sentinel 主地址端口是 6379'); + expect(result.content).not.toContain('redis-secret'); + expect(result.content).not.toContain('sentinel-secret'); + }); }); diff --git a/frontend/src/components/ai/aiRedisTopologyInsights.test.ts b/frontend/src/components/ai/aiRedisTopologyInsights.test.ts new file mode 100644 index 0000000..61cf044 --- /dev/null +++ b/frontend/src/components/ai/aiRedisTopologyInsights.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; + +import type { SavedConnection } from '../../types'; +import { buildRedisTopologySnapshot } from './aiRedisTopologyInsights'; + +const buildRedisConnection = ( + id: string, + name: string, + config: Partial, + extra: Partial = {}, +): SavedConnection => ({ + id, + name, + config: { + type: 'redis', + host: '127.0.0.1', + port: 6379, + user: '', + ...config, + }, + ...extra, +}); + +describe('buildRedisTopologySnapshot', () => { + it('summarizes Redis Sentinel settings without exposing passwords', () => { + const snapshot = buildRedisTopologySnapshot({ + connections: [ + buildRedisConnection('redis-sentinel', '生产 Redis Sentinel', { + host: 'sentinel-a.local', + port: 6379, + hosts: ['sentinel-b.local:26379'], + topology: 'sentinel', + user: 'app', + password: 'redis-secret', + redisSentinelUser: 'sentinel-user', + redisSentinelPassword: 'sentinel-secret', + redisDB: 2, + useSSL: true, + sslMode: 'required', + }, { + hasRedisSentinelPassword: true, + }), + ], + keyword: '生产', + }); + + expect(snapshot.totalRedisConnections).toBe(1); + expect(snapshot.totalMatched).toBe(1); + expect(snapshot.topologyBreakdown).toEqual({ sentinel: 1 }); + expect(snapshot.connections[0].sentinelMaster).toBe(''); + expect(snapshot.connections[0].hasRedisAuth).toBe(true); + expect(snapshot.connections[0].hasSentinelAuth).toBe(true); + expect(snapshot.connections[0].warnings).toContain('Sentinel master 名称为空,go-redis FailoverClient 无法发现主节点'); + expect(snapshot.connections[0].warnings).toContain('Sentinel 主地址端口是 6379,请确认这里填写的是 Sentinel 端口,常见默认值是 26379'); + expect(JSON.stringify(snapshot)).not.toContain('redis-secret'); + expect(JSON.stringify(snapshot)).not.toContain('sentinel-secret'); + }); + + it('reports cluster logical-db and SSH risks', () => { + const snapshot = buildRedisTopologySnapshot({ + connections: [ + buildRedisConnection('redis-cluster', '订单 Redis Cluster', { + host: '10.10.1.10', + port: 6379, + hosts: ['10.10.1.11:6379', '10.10.1.12:6379'], + topology: 'cluster', + redisDB: 4, + useSSH: true, + }, { + includeRedisDatabases: [0, 4], + }), + ], + connectionId: 'redis-cluster', + }); + + expect(snapshot.connections[0].topology).toBe('cluster'); + expect(snapshot.connections[0].seedAddressCount).toBe(3); + 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].recommendations?.join('\n')).toContain('种子节点'); + }); + + it('filters out non-Redis connections and warns about multi-host single mode', () => { + const snapshot = buildRedisTopologySnapshot({ + connections: [ + buildRedisConnection('redis-single', '缓存单机', { + host: 'redis.local', + port: 6379, + topology: 'single', + hosts: ['redis-2.local:6379'], + }), + { + id: 'mysql-1', + name: '业务库', + config: { + type: 'mysql', + host: 'mysql.local', + port: 3306, + user: 'root', + }, + }, + ], + }); + + expect(snapshot.totalRedisConnections).toBe(1); + expect(snapshot.connections).toHaveLength(1); + expect(snapshot.connections[0].warnings).toContain('单机模式下存在多个节点地址,后端会按多节点集群路径处理,建议显式改为 Cluster 模式'); + }); +}); diff --git a/frontend/src/components/ai/aiRedisTopologyInsights.ts b/frontend/src/components/ai/aiRedisTopologyInsights.ts new file mode 100644 index 0000000..6b18d2c --- /dev/null +++ b/frontend/src/components/ai/aiRedisTopologyInsights.ts @@ -0,0 +1,207 @@ +import type { SavedConnection } from '../../types'; + +const normalizeText = (input: unknown): string => String(input || '').trim(); + +const normalizeLowerText = (input: unknown): string => normalizeText(input).toLowerCase(); + +const normalizeLimit = (input: unknown, fallback: number, max: number): number => { + const value = Math.floor(Number(input) || fallback); + if (value < 1) return 1; + if (value > max) return max; + return value; +}; + +const normalizeRedisTopology = (input: unknown): 'single' | 'cluster' | 'sentinel' => { + const value = normalizeLowerText(input); + if (value === 'cluster') return 'cluster'; + if (value === 'sentinel') return 'sentinel'; + return 'single'; +}; + +const buildSeedAddresses = (connection: SavedConnection): string[] => { + const config = connection.config || {}; + const defaultPort = Number.isFinite(Number(config.port)) ? Number(config.port) : 6379; + const primary = normalizeText(config.host) + ? `${normalizeText(config.host)}:${defaultPort}` + : ''; + const extraHosts = Array.isArray(config.hosts) + ? config.hosts.map((host) => normalizeText(host)).filter(Boolean) + : []; + return [primary, ...extraHosts].filter(Boolean); +}; + +const matchesKeyword = (keyword: string, connection: SavedConnection, seedAddresses: string[]): boolean => { + if (!keyword) return true; + const config = connection.config || {}; + return [ + connection.id, + connection.name, + config.type, + config.host, + config.user, + config.database, + config.topology, + config.redisSentinelMaster, + config.redisSentinelUser, + ...seedAddresses, + ].some((field) => normalizeLowerText(field).includes(keyword)); +}; + +const buildRedisTopologyWarnings = (connection: SavedConnection, seedAddresses: string[]): string[] => { + const config = connection.config || {}; + const topology = normalizeRedisTopology(config.topology); + 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 (topology === 'sentinel') { + if (!normalizeText(config.redisSentinelMaster)) { + warnings.push('Sentinel master 名称为空,go-redis FailoverClient 无法发现主节点'); + } + if (seedAddresses.length < 2) { + warnings.push('Sentinel 只配置了一个节点,建议至少填写 2-3 个 Sentinel 地址以避免单点失败'); + } + if (Number(config.port) === 6379) { + warnings.push('Sentinel 主地址端口是 6379,请确认这里填写的是 Sentinel 端口,常见默认值是 26379'); + } + } + if (topology === 'cluster') { + if (seedAddresses.length < 2) { + warnings.push('Cluster 只配置了一个种子节点,建议填写多个 master/replica 节点提高发现成功率'); + } + const redisDB = Number(config.redisDB || 0); + const includeRedisDatabases = Array.isArray(connection.includeRedisDatabases) + ? connection.includeRedisDatabases.filter((item) => typeof item === 'number') + : []; + if (redisDB > 0 || includeRedisDatabases.some((item) => item > 0)) { + warnings.push('Redis Cluster 物理上只支持 db0;GoNavi 会用 __gonavi_db_N__: 前缀模拟逻辑库隔离'); + } + if (normalizeText(config.redisSentinelMaster) || normalizeText(config.redisSentinelUser)) { + warnings.push('Cluster 模式下 Sentinel master / Sentinel 用户字段不会生效'); + } + } + if (topology === 'single') { + if (seedAddresses.length > 1) { + warnings.push('单机模式下存在多个节点地址,后端会按多节点集群路径处理,建议显式改为 Cluster 模式'); + } + if (normalizeText(config.redisSentinelMaster) || normalizeText(config.redisSentinelUser)) { + warnings.push('单机模式下 Sentinel 字段不会生效,如需哨兵发现请切换为 Sentinel 模式'); + } + } + + return warnings; +}; + +const buildRedisTopologyRecommendations = (connection: SavedConnection, warnings: string[]): string[] => { + const config = connection.config || {}; + const topology = normalizeRedisTopology(config.topology); + const recommendations: string[] = []; + + if (topology === 'sentinel') { + recommendations.push('确认主机和附加节点填写的是 Sentinel 地址,不是 Redis master 地址'); + recommendations.push('分别填写 Redis 数据节点账号密码和 Sentinel 自身账号密码,二者不要混用'); + } else if (topology === 'cluster') { + recommendations.push('优先配置 2 个以上种子节点,并确认这些节点属于同一个 Redis Cluster'); + recommendations.push('如果需要多库视图,优先在业务 key 上显式使用命名空间,避免误解 Cluster 的物理 db0 限制'); + } else { + recommendations.push('单机模式只填写一个 Redis 地址;如果有多个节点,请改用 Cluster 或 Sentinel 模式'); + } + + if (warnings.some((warning) => warning.includes('SSH'))) { + recommendations.push('跨网络访问 Redis Cluster/Sentinel 时,优先使用网络代理、VPN 或 GoNavi MCP HTTP,而不是单端口 SSH 隧道'); + } + if (config.useSSL === true) { + recommendations.push('已启用 TLS,连接失败时优先核对 sslMode、CA/证书路径和服务端 SNI'); + } + return recommendations; +}; + +export const buildRedisTopologySnapshot = (params: { + connections: SavedConnection[]; + connectionId?: unknown; + keyword?: unknown; + limit?: unknown; + includeRecommendations?: unknown; +}) => { + const { + connections, + connectionId, + keyword, + limit, + includeRecommendations, + } = params; + const safeConnectionId = normalizeText(connectionId); + const safeKeyword = normalizeLowerText(keyword); + const safeLimit = normalizeLimit(limit, 20, 100); + const shouldIncludeRecommendations = includeRecommendations !== false; + + const redisConnections = connections.filter((connection) => + normalizeLowerText(connection.config?.type) === 'redis', + ); + const matchedConnections = redisConnections.filter((connection) => { + if (safeConnectionId && connection.id !== safeConnectionId) { + return false; + } + return matchesKeyword(safeKeyword, connection, buildSeedAddresses(connection)); + }); + + const topologyBreakdown = matchedConnections.reduce>((acc, connection) => { + const key = normalizeRedisTopology(connection.config?.topology); + acc[key] = (acc[key] || 0) + 1; + return acc; + }, {}); + + const visibleConnections = matchedConnections.slice(0, safeLimit).map((connection) => { + const config = connection.config || {}; + const topology = normalizeRedisTopology(config.topology); + const seedAddresses = buildSeedAddresses(connection); + const warnings = buildRedisTopologyWarnings(connection, seedAddresses); + const includeRedisDatabases = Array.isArray(connection.includeRedisDatabases) + ? connection.includeRedisDatabases.filter((item) => typeof item === 'number') + : []; + return { + id: connection.id, + name: connection.name, + topology, + host: normalizeText(config.host), + port: Number.isFinite(Number(config.port)) ? Number(config.port) : null, + seedAddresses, + seedAddressCount: seedAddresses.length, + redisDB: typeof config.redisDB === 'number' ? config.redisDB : 0, + includeRedisDatabases, + useSSL: config.useSSL === true, + sslMode: config.sslMode || '', + useSSH: config.useSSH === true, + useProxy: config.useProxy === true, + useHttpTunnel: config.useHttpTunnel === true, + sentinelMaster: topology === 'sentinel' ? normalizeText(config.redisSentinelMaster) : '', + hasRedisAuth: Boolean(normalizeText(config.user) || normalizeText(config.password) || connection.hasPrimaryPassword === true), + hasSentinelAuth: topology === 'sentinel' + ? Boolean(normalizeText(config.redisSentinelUser) || normalizeText(config.redisSentinelPassword) || connection.hasRedisSentinelPassword === true) + : false, + warnings, + recommendations: shouldIncludeRecommendations + ? buildRedisTopologyRecommendations(connection, warnings) + : undefined, + }; + }); + + const warningCount = visibleConnections.reduce((total, connection) => total + connection.warnings.length, 0); + + return { + connectionId: safeConnectionId, + keyword: safeKeyword, + limit: safeLimit, + totalRedisConnections: redisConnections.length, + totalMatched: matchedConnections.length, + returnedConnections: visibleConnections.length, + truncated: matchedConnections.length > visibleConnections.length, + topologyBreakdown, + warningCount, + connections: visibleConnections, + }; +}; diff --git a/frontend/src/components/ai/aiSnapshotInspectionConnectionToolExecutor.ts b/frontend/src/components/ai/aiSnapshotInspectionConnectionToolExecutor.ts index 9c04e9a..5d78e40 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionConnectionToolExecutor.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionConnectionToolExecutor.ts @@ -8,6 +8,7 @@ import { buildAIContextSnapshot } from './aiContextInsights'; import { buildConnectionCapabilitiesSnapshot } from './aiConnectionCapabilitiesInsights'; import { buildCurrentConnectionSnapshot } from './aiConnectionInsights'; import { buildSavedConnectionsSnapshot } from './aiSavedConnectionInsights'; +import { buildRedisTopologySnapshot } from './aiRedisTopologyInsights'; import { buildExternalSQLFileSnapshot } from './aiExternalSqlFileInsights'; import { buildExternalSQLDirectoriesSnapshot } from './aiExternalSqlInsights'; import { findBestMatchingExternalSQLDirectory } from './aiExternalSqlPathUtils'; @@ -75,6 +76,17 @@ export async function executeConnectionWorkspaceSnapshotToolCall({ })), success: true, }; + case 'inspect_redis_topology': + return { + content: JSON.stringify(buildRedisTopologySnapshot({ + connections, + connectionId: args.connectionId, + keyword: args.keyword, + limit: args.limit, + includeRecommendations: args.includeRecommendations, + })), + success: true, + }; case 'inspect_external_sql_directories': return { content: JSON.stringify(buildExternalSQLDirectoriesSnapshot({ diff --git a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts index afc2d55..a43eac0 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts @@ -163,6 +163,7 @@ export async function executeSnapshotInspectionToolCall( inspect_current_connection: '读取当前连接失败', inspect_connection_capabilities: '读取当前连接能力矩阵失败', inspect_saved_connections: '读取本地连接清单失败', + inspect_redis_topology: '读取 Redis 拓扑配置失败', inspect_external_sql_directories: '读取外部 SQL 目录失败', inspect_external_sql_file: '读取外部 SQL 文件失败', inspect_ai_sessions: '读取本地 AI 会话清单失败', diff --git a/frontend/src/components/ai/aiSystemInspectionGuidance.ts b/frontend/src/components/ai/aiSystemInspectionGuidance.ts index 377622e..86420ab 100644 --- a/frontend/src/components/ai/aiSystemInspectionGuidance.ts +++ b/frontend/src/components/ai/aiSystemInspectionGuidance.ts @@ -194,6 +194,12 @@ export const appendDatabaseInspectionGuidanceMessages = ( 'inspect_saved_connections', '如果用户提到“本地存了哪些连接”“帮我找 mysql / postgres / redis 连接”“哪条连接配了 SSH/代理”,优先调用 inspect_saved_connections 读取真实本地连接清单,再决定继续查看哪条连接。', ); + appendGuidanceIfToolAvailable( + messages, + availableToolNames, + 'inspect_redis_topology', + '如果用户提到“Redis 哨兵/集群”“Sentinel master”“Redis Cluster 多库”“切换 Redis DB 失败”“Redis 多节点怎么填”,优先调用 inspect_redis_topology 读取真实 Redis 拓扑、节点、认证状态和风险提示,不要凭默认端口或经验猜测。', + ); appendGuidanceIfToolAvailable( messages, availableToolNames, diff --git a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx index ac340a9..e0114b6 100644 --- a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx +++ b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx @@ -49,6 +49,7 @@ const TOOL_ACTION_LABELS: Record = { inspect_current_connection: '读取当前连接摘要', inspect_connection_capabilities: '读取当前连接能力矩阵', inspect_saved_connections: '盘点本地已保存连接', + inspect_redis_topology: '诊断 Redis 拓扑配置', inspect_external_sql_directories: '盘点外部 SQL 目录', inspect_external_sql_file: '读取外部 SQL 文件', inspect_ai_sessions: '盘点本地 AI 历史会话', diff --git a/frontend/src/utils/aiBuiltinInspectionContextToolInfo.ts b/frontend/src/utils/aiBuiltinInspectionContextToolInfo.ts index 49f5972..9686c16 100644 --- a/frontend/src/utils/aiBuiltinInspectionContextToolInfo.ts +++ b/frontend/src/utils/aiBuiltinInspectionContextToolInfo.ts @@ -104,6 +104,31 @@ export const BUILTIN_AI_INSPECTION_CONTEXT_TOOL_INFO: AIBuiltinToolInfo[] = [ }, }, }, + { + name: "inspect_redis_topology", + icon: "🧰", + desc: "诊断 Redis 单机/哨兵/集群配置", + detail: + "读取本地 Redis 连接拓扑摘要,返回单机、Sentinel、Cluster 的节点、master、认证状态、DB 范围和风险提示。适合用户问 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 密码。", + parameters: { + type: "object", + properties: { + connectionId: { type: "string", description: "可选,只诊断某个 Redis 连接 ID" }, + keyword: { type: "string", description: "可选,按连接名、地址、拓扑、Sentinel master 或节点地址筛选" }, + limit: { type: "number", description: "可选,最多返回多少条 Redis 连接,默认 20,最大 100" }, + includeRecommendations: { type: "boolean", description: "可选,是否返回修复建议,默认 true" }, + }, + }, + }, + }, + }, { name: "inspect_external_sql_directories", icon: "🗂️", diff --git a/frontend/src/utils/aiToolRegistry.test.ts b/frontend/src/utils/aiToolRegistry.test.ts index 40cd97e..863e891 100644 --- a/frontend/src/utils/aiToolRegistry.test.ts +++ b/frontend/src/utils/aiToolRegistry.test.ts @@ -147,6 +147,15 @@ describe('aiToolRegistry', () => { expect(info?.tool.function.description).toContain('本地已保存连接清单'); }); + it('registers the Redis topology inspector as a builtin tool', () => { + const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_redis_topology'); + expect(info).toBeTruthy(); + expect(info?.desc).toContain('Redis 单机/哨兵/集群'); + expect(info?.tool.function.description).toContain('Sentinel'); + expect(info?.tool.function.description).toContain('不会回显 Redis 密码'); + expect(info?.tool.function.parameters?.properties?.connectionId?.description).toContain('Redis 连接 ID'); + }); + it('registers the external-sql-directory inspector as a builtin tool', () => { const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_external_sql_directories'); expect(info).toBeTruthy(); @@ -280,6 +289,7 @@ describe('aiToolRegistry', () => { expect(tools.some((item) => item.function.name === 'inspect_current_connection')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_connection_capabilities')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_saved_connections')).toBe(true); + expect(tools.some((item) => item.function.name === 'inspect_redis_topology')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_external_sql_directories')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_external_sql_file')).toBe(true); expect(tools.some((item) => item.function.name === 'inspect_recent_sql_activity')).toBe(true);