feat(ai): 增强 Redis 拓扑诊断能力

- 增加 Redis Sentinel/Cluster 状态分级、阻断原因、后端适配器和脱敏 URI 示例

- 区分配置拓扑与后端实际拓扑,修正多节点单机配置的 ClusterClient 诊断

- 补充 AI 工具目录流程与 Redis 哨兵/集群提示文案
This commit is contained in:
Syngnat
2026-06-12 10:05:01 +08:00
parent 8eb4bf3954
commit e83c9f5553
6 changed files with 211 additions and 14 deletions

View File

@@ -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:<hidden>@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 物理上只支持 db0GoNavi 会用 __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 模式');
});
});

View File

@@ -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', '<hidden>');
}
} 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 ? ':<hidden>' : ''}@`
: hasRedisPassword
? ':<hidden>@'
: '';
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 物理只支持 db0GoNavi 用 __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,
};
};

View File

@@ -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: {

View File

@@ -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',

View File

@@ -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 租户');

View File

@@ -118,7 +118,7 @@ export const getConnectionTypeHint = (type: string): string => {
case 'custom':
return '自定义驱动与 DSN';
case 'redis':
return '单机 / 集群';
return '单机 / 哨兵 / 集群';
case 'mongodb':
return '单机 / 副本集';
case 'elasticsearch':