fix(queries): MySQL 实例误调 Redis 接口 500 + 点表生成 SELECT + 光标多行闪烁
=== 一、/queries 打开 MySQL 实例报 500 ===
现象:GET /api/v1/redis/5/dbs → 500
后端日志 redis: invalid reply: "J\x00\x00\x00\n"
("J\x00\x00\x00" 是 MySQL 握手包,说明用 Redis 协议连了 3306)
根因:SchemaSidebar 是 mysql/redis 共用的组件,其中
const redisDbs = useRedisDbs(Number(instanceId));
是无条件调用(React hooks 不能条件调用),而 useRedisDbs 内部
只看 instanceId 就请求 /redis/{id}/dbs →
MySQL 实例也发 Redis 请求 → 后端用 Redis 协议连 MySQL → 500。
修复(前后端双保险):
1. useRedisDbs 增加 enabled 参数(默认 true),false 时不发请求并复位 [0]/false
2. SchemaSidebar 传 isRedis:useRedisDbs(Number(instanceId), isRedis)
3. redis.go getRedisClient 前置校验 DBType != "redis" 直接返回
4. resp 包新增 UserFacingError / UserFacing(),InternalErr 识别后返回
400 + 可读原因(不再把所有错误笼统包装成 500 "服务器内部错误")
验证:
GET /redis/5/dbs(MySQL)→ 400
{"code":"bad_request","detail":"实例「pre-mysql-只读」的类型是 mysql,
不是 redis。Redis 接口只适用于 redis 实例;MySQL/PostgreSQL 请使用
/api/v1/instances/5/databases 等对应接口"}
GET /instances/5/databases → 200
GET /redis/4/dbs(Redis 集群)→ 200 {"cluster":true,"dbs":[0]}
=== 二、左侧栏点表不生成 SELECT ===
原交互:单击=展开/折叠,双击=执行 SELECT,Alt+点击=插入表名。
需求:单击表 → 在右侧编辑器生成 SELECT * FROM <db>.<table>
修复:
1. SchemaSidebar 表节点:箭头图标包 data-chevron 单独触发展开/折叠;
点表名区域 → onInsert(previewSelectSql(...) + ';\n')
Alt+点击仍为只插表名;双击仍为直接执行
2. QueriesPage onInsert:生成的文本以 SELECT 开头时「替换」编辑器内容
(原来一律追加,会和上一条语句拼成非法 SQL);表名/字段名仍追加
=== 三、SQL 编辑器光标同时在多行闪烁 ===
根因:.cm-cursor 强制了 height:1.25em !important + marginTop:0.15em,
而 .cm-line 行高是 1.55em,光标高度溢出到相邻行,视觉上像两行同时闪。
修复:光标样式只保留颜色与 1.5px 宽度,height/marginTop 交回 CodeMirror 自行计算。
镜像:lghost/opsxsql:latest + v0.1
This commit is contained in:
1 parent
0427b87412
commit
36724bba94
6 files changed
+72
-12
No files matched your search
@@ -351,6 +351,18 @@ func writeRedisKeyValue(ctx context.Context, client *redisClientWrapper, key str
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getRedisClient(instance *model.Instance, db int) (*redisClientWrapper, error) {
|
func getRedisClient(instance *model.Instance, db int) (*redisClientWrapper, error) {
|
||||||
|
// 防御:非 Redis 实例不能走 Redis 协议。
|
||||||
|
// 若前端误调 /api/v1/redis/:id/*(如共用组件的 MySQL 实例),
|
||||||
|
// 用 Redis 协议连 3306 会收到 MySQL 握手包,报
|
||||||
|
// `redis: invalid reply: "J\x00\x00\x00\n"` 并抛 500。
|
||||||
|
// 这里提前拦截,给出可读错误。
|
||||||
|
if instance.DBType != "redis" {
|
||||||
|
return nil, resp.UserFacing(fmt.Sprintf(
|
||||||
|
"实例「%s」的类型是 %s,不是 redis。Redis 接口只适用于 redis 实例;"+
|
||||||
|
"MySQL/PostgreSQL 请使用 /api/v1/instances/%d/databases 等对应接口",
|
||||||
|
instance.Name, instance.DBType, instance.ID))
|
||||||
|
}
|
||||||
|
|
||||||
password := instance.Password
|
password := instance.Password
|
||||||
// Decrypt if encrypted
|
// Decrypt if encrypted
|
||||||
if strings.HasPrefix(password, "enc:") {
|
if strings.HasPrefix(password, "enc:") {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
package resp
|
package resp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -54,7 +55,25 @@ func Forbidden(c *gin.Context, detail string) { Fail(c, http.StatusForbidden,
|
|||||||
func NotFound(c *gin.Context, detail string) { Fail(c, http.StatusNotFound, detail) }
|
func NotFound(c *gin.Context, detail string) { Fail(c, http.StatusNotFound, detail) }
|
||||||
|
|
||||||
// InternalErr 500:服务端 log 完整错误,客户端只收通用文案(防 DB/内部错误原文泄漏)
|
// InternalErr 500:服务端 log 完整错误,客户端只收通用文案(防 DB/内部错误原文泄漏)
|
||||||
|
// UserFacingError 标记「可以展示给用户」的错误。
|
||||||
|
//
|
||||||
|
// 典型场景:调用方用错了接口/参数不满足前置条件(例如拿 MySQL 实例去调
|
||||||
|
// /api/v1/redis/:id/*),这既不是服务端故障也不该泄漏内部细节,
|
||||||
|
// 用 400 + 明确原因比 500 + "服务器内部错误" 更有助于定位。
|
||||||
|
type UserFacingError struct{ Msg string }
|
||||||
|
|
||||||
|
func (e *UserFacingError) Error() string { return e.Msg }
|
||||||
|
|
||||||
|
// UserFacing 构造一个可展示给用户的错误
|
||||||
|
func UserFacing(msg string) error { return &UserFacingError{Msg: msg} }
|
||||||
|
|
||||||
func InternalErr(c *gin.Context, err error) {
|
func InternalErr(c *gin.Context, err error) {
|
||||||
|
var uf *UserFacingError
|
||||||
|
if errors.As(err, &uf) {
|
||||||
|
log.Printf("[resp] bad request on %s %s: %v", c.Request.Method, c.Request.URL.Path, err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": uf.Msg, "code": "bad_request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
log.Printf("[resp] internal error on %s %s: %v", c.Request.Method, c.Request.URL.Path, err)
|
log.Printf("[resp] internal error on %s %s: %v", c.Request.Method, c.Request.URL.Path, err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "服务器内部错误,请稍后重试", "code": "internal"})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "服务器内部错误,请稍后重试", "code": "internal"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ export default function SchemaSidebar({
|
|||||||
|
|
||||||
// -- Redis State(浏览逻辑在共享 RedisKeyBrowser,这里只留库选择与详情预览) --
|
// -- Redis State(浏览逻辑在共享 RedisKeyBrowser,这里只留库选择与详情预览) --
|
||||||
const [redisDb, setRedisDb] = useState(0);
|
const [redisDb, setRedisDb] = useState(0);
|
||||||
const redisDbs = useRedisDbs(Number(instanceId));
|
// 仅 Redis 实例才拉库列表,避免 MySQL/Mongo 也打到 /redis/:id/dbs
|
||||||
|
const redisDbs = useRedisDbs(Number(instanceId), isRedis);
|
||||||
const ctxMenu = useContextMenu();
|
const ctxMenu = useContextMenu();
|
||||||
const dialect: SqlDialect = dbType === 'postgresql' ? 'postgresql' : 'mysql';
|
const dialect: SqlDialect = dbType === 'postgresql' ? 'postgresql' : 'mysql';
|
||||||
|
|
||||||
@@ -407,22 +408,32 @@ export default function SchemaSidebar({
|
|||||||
<div key={t.name}>
|
<div key={t.name}>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
// 点左侧箭头 → 展开/折叠;点表名区域 → 生成 SELECT 填进编辑器
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('[data-chevron]')) {
|
||||||
|
handleExpandTable(dbName, t.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.altKey) {
|
if (e.altKey) {
|
||||||
|
// Alt+点击:只插入表名,方便手写 SQL
|
||||||
onInsert(`${dbName}.${t.name}`);
|
onInsert(`${dbName}.${t.name}`);
|
||||||
} else {
|
} else {
|
||||||
handleExpandTable(dbName, t.name);
|
// 单击:生成 SELECT * FROM <db>.<table>; 到右侧查询编辑器
|
||||||
|
onInsert(`${previewSelectSql(t.name, dialect, dbName)};\n`);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDoubleClick={() => onExecuteSql?.(previewSelectSql(t.name, dialect, dbName))}
|
onDoubleClick={() => onExecuteSql?.(previewSelectSql(t.name, dialect, dbName))}
|
||||||
onContextMenu={(e) => ctxMenu.open(e, tableMenuItems(dbName, t.name))}
|
onContextMenu={(e) => ctxMenu.open(e, tableMenuItems(dbName, t.name))}
|
||||||
className="flex items-center gap-1.5 w-full px-2 py-1 text-[11px] hover:bg-[var(--dg-bg-hover)] transition-colors group"
|
className="flex items-center gap-1.5 w-full px-2 py-1 text-[11px] hover:bg-[var(--dg-bg-hover)] transition-colors group"
|
||||||
title={`单击展开/折叠 · 双击查看数据 · Alt+点击插入表名 · 右键更多`}
|
title={`单击生成 SELECT · 点箭头展开字段 · 双击直接查数据 · Alt+点击只插表名 · 右键更多`}
|
||||||
>
|
>
|
||||||
|
<span data-chevron className="flex-shrink-0 cursor-pointer">
|
||||||
{isTableExpanded ? (
|
{isTableExpanded ? (
|
||||||
<ChevronDown className="w-2.5 h-2.5 text-[var(--dg-text-muted)] flex-shrink-0" />
|
<ChevronDown className="w-2.5 h-2.5 text-[var(--dg-text-muted)]" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className="w-2.5 h-2.5 text-[var(--dg-text-muted)] flex-shrink-0" />
|
<ChevronRight className="w-2.5 h-2.5 text-[var(--dg-text-muted)]" />
|
||||||
)}
|
)}
|
||||||
|
</span>
|
||||||
<Table2 className="w-2.5 h-2.5 text-blue-400 flex-shrink-0" />
|
<Table2 className="w-2.5 h-2.5 text-blue-400 flex-shrink-0" />
|
||||||
<span className="font-mono truncate flex-1 text-left hover:text-blue-400 transition-colors">
|
<span className="font-mono truncate flex-1 text-left hover:text-blue-400 transition-colors">
|
||||||
{t.name}
|
{t.name}
|
||||||
|
|||||||
@@ -306,12 +306,12 @@ const appTheme = EditorView.theme({
|
|||||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace",
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace",
|
||||||
lineHeight: '1.55',
|
lineHeight: '1.55',
|
||||||
},
|
},
|
||||||
|
// 光标只改颜色/粗细,不要强改 height 与 marginTop:
|
||||||
|
// CodeMirror 按行高自行计算光标高度,强制 1.25em + marginTop 会溢出到相邻行,
|
||||||
|
// 视觉上表现为「光标同时在两行闪烁」。
|
||||||
'.cm-cursor': {
|
'.cm-cursor': {
|
||||||
borderLeftColor: 'var(--dg-text-primary)',
|
borderLeftColor: 'var(--dg-text-primary)',
|
||||||
borderLeftWidth: '1.5px',
|
borderLeftWidth: '1.5px',
|
||||||
height: '1.25em !important',
|
|
||||||
marginTop: '0.15em',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
},
|
},
|
||||||
'.cm-line': {
|
'.cm-line': {
|
||||||
lineHeight: '1.55',
|
lineHeight: '1.55',
|
||||||
|
|||||||
@@ -151,13 +151,26 @@ export function useRedisKeyBrowser(instanceId: number, db: number): UseRedisKeyB
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 库列表(集群恒 [0]);实例变化自动重拉 */
|
/** 库列表(集群恒 [0]);实例变化自动重拉。
|
||||||
export function useRedisDbs(instanceId: number): { dbs: number[]; cluster: boolean } {
|
*
|
||||||
|
* enabled=false 时不发请求 —— React hooks 不能条件调用,而 SchemaSidebar
|
||||||
|
* 是 mysql/redis 共用的组件,若不加此开关,打开 MySQL 实例也会去请求
|
||||||
|
* /redis/{id}/dbs,后端拿 Redis 协议连 MySQL 会收到 MySQL 握手包,
|
||||||
|
* 直接 500(redis: invalid reply)。调用方应传 dbType === 'redis'。
|
||||||
|
*/
|
||||||
|
export function useRedisDbs(
|
||||||
|
instanceId: number,
|
||||||
|
enabled = true,
|
||||||
|
): { dbs: number[]; cluster: boolean } {
|
||||||
const [dbs, setDbs] = useState<number[]>([0]);
|
const [dbs, setDbs] = useState<number[]>([0]);
|
||||||
const [cluster, setCluster] = useState(false);
|
const [cluster, setCluster] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId) return;
|
if (!instanceId || !enabled) {
|
||||||
|
setDbs([0]);
|
||||||
|
setCluster(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
redisApi
|
redisApi
|
||||||
.getDbs(instanceId)
|
.getDbs(instanceId)
|
||||||
@@ -172,7 +185,7 @@ export function useRedisDbs(instanceId: number): { dbs: number[]; cluster: boole
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [instanceId]);
|
}, [instanceId, enabled]);
|
||||||
|
|
||||||
return { dbs, cluster };
|
return { dbs, cluster };
|
||||||
}
|
}
|
||||||
@@ -456,7 +456,12 @@ export default function QueriesPage() {
|
|||||||
onInsert={(text) => {
|
onInsert={(text) => {
|
||||||
if (dbType === 'redis') {
|
if (dbType === 'redis') {
|
||||||
setSql(text);
|
setSql(text);
|
||||||
|
} else if (/^\s*SELECT\b/i.test(text)) {
|
||||||
|
// 左侧栏点表生成的 SELECT 语句:直接替换编辑器内容,
|
||||||
|
// 避免和上一条语句拼接成非法 SQL
|
||||||
|
setSql(text);
|
||||||
} else {
|
} else {
|
||||||
|
// 插入表名/字段名:追加到现有内容后
|
||||||
setSql((prev) => prev + (prev.trim() ? ' ' : '') + text + ' ');
|
setSql((prev) => prev + (prev.trim() ? ' ' : '') + text + ' ');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in new issue
Block a user