refactor: 数据源菜单合一+布局优化

- 4个数据源菜单(MySQL/PG/Redis/Mongo)合并为1个「数据源」
- InstancePage 改为统一入口,顶部Tab切换类型过滤
- 添加数据源改为弹框Modal(默认MySQL)
- 去掉左下角用户区,退出登录移到右上角header
- 侧边栏收窄220px,整体布局更紧凑
- 后端202 tests passing
- 前端构建零错误
This commit is contained in:
logan committed 2026-05-22 22:53:48 +08:00
1 parent bab55d3927
commit 5cbd0e3441
23 files changed
+2881 -94

No files matched your search

+240
View File
@@ -0,0 +1,240 @@
"""数据字典服务 — 通过 DBConnector 连接数据源获取元数据。"""
import re
from typing import List, Dict, Any, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.instance import Instance
from app.services.db_connector import DBConnector
from app.core.crypto import decrypt_password
async def _get_instance(session: AsyncSession, instance_id: int) -> Instance:
"""根据 ID 查询实例,解密密码后返回。"""
result = await session.execute(select(Instance).where(Instance.id == instance_id))
instance = result.scalar_one_or_none()
if not instance:
return None
instance.password = decrypt_password(instance.password)
return instance
async def list_databases(session: AsyncSession, instance_id: int) -> Optional[List[str]]:
"""获取实例的数据库列表。"""
instance = await _get_instance(session, instance_id)
if not instance:
return None
if instance.db_type == "mysql":
qr = await DBConnector.execute_query(
instance, None, "SHOW DATABASES", user_id=0
)
system_dbs = {"information_schema", "mysql", "performance_schema", "sys"}
return [r.get("Database", r.get("database", "")) for r in qr.rows
if r.get("Database", r.get("database", "")) not in system_dbs]
elif instance.db_type in ("postgres", "postgresql"):
qr = await DBConnector.execute_query(
instance, "postgres",
"SELECT datname FROM pg_database WHERE datistemplate = false",
user_id=0,
)
return [r.get("datname", "") for r in qr.rows]
elif instance.db_type == "sqlite":
return [instance.database or "main"]
else:
return []
async def list_tables(
session: AsyncSession, instance_id: int, database: str
) -> List[str]:
"""获取指定数据库的表名列表。"""
instance = await _get_instance(session, instance_id)
if not instance:
return []
if instance.db_type == "mysql":
sql = (
"SELECT TABLE_NAME FROM information_schema.TABLES "
"WHERE TABLE_SCHEMA = '" + database + "' AND TABLE_TYPE = 'BASE TABLE'"
)
qr = await DBConnector.execute_query(instance, database, sql, user_id=0)
return [r.get("TABLE_NAME", "") for r in qr.rows]
elif instance.db_type in ("postgres", "postgresql"):
sql = "SELECT tablename FROM pg_tables WHERE schemaname = 'public'"
qr = await DBConnector.execute_query(instance, database, sql, user_id=0)
return [r.get("tablename", "") for r in qr.rows]
elif instance.db_type == "sqlite":
sql = "SELECT name FROM sqlite_master WHERE type='table'"
qr = await DBConnector.execute_query(instance, None, sql, user_id=0)
return [r.get("name", "") for r in qr.rows]
else:
return []
async def get_table_detail(
session: AsyncSession, instance_id: int, database: str, table_name: str
) -> Dict[str, Any]:
"""获取表详情: columns / indexes / create_ddl / row_count。"""
instance = await _get_instance(session, instance_id)
if not instance:
return {}
columns = []
indexes = []
create_ddl = ""
row_count = 0
if instance.db_type == "mysql":
# Columns
col_sql = (
"SELECT COLUMN_NAME as Field, COLUMN_TYPE as Type, "
"IS_NULLABLE as `Null`, COLUMN_KEY as `Key`, "
"COLUMN_DEFAULT as `Default`, EXTRA as Extra, "
"COLUMN_COMMENT as Comment "
"FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA = '" + database + "' "
"AND TABLE_NAME = '" + table_name + "' "
"ORDER BY ORDINAL_POSITION"
)
col_qr = await DBConnector.execute_query(instance, database, col_sql, user_id=0)
columns = col_qr.rows
# Indexes
idx_sql = "SHOW INDEX FROM `" + table_name + "`"
idx_qr = await DBConnector.execute_query(instance, database, idx_sql, user_id=0)
# Group by index name
idx_map = {}
for r in idx_qr.rows:
idx_name = r.get("Key_name", "")
if idx_name not in idx_map:
idx_map[idx_name] = {
"name": idx_name,
"columns": [],
"non_unique": r.get("Non_unique", 1),
"index_type": r.get("Index_type", "BTREE"),
}
idx_map[idx_name]["columns"].append(r.get("Column_name", ""))
indexes = list(idx_map.values())
# Create DDL
ddl_sql = "SHOW CREATE TABLE `" + table_name + "`"
ddl_qr = await DBConnector.execute_query(instance, database, ddl_sql, user_id=0)
if ddl_qr.rows:
create_ddl = ddl_qr.rows[0].get("Create Table", "")
# Row count
cnt_sql = (
"SELECT TABLE_ROWS FROM information_schema.TABLES "
"WHERE TABLE_SCHEMA = '" + database + "' "
"AND TABLE_NAME = '" + table_name + "'"
)
cnt_qr = await DBConnector.execute_query(instance, database, cnt_sql, user_id=0)
if cnt_qr.rows:
row_count = cnt_qr.rows[0].get("TABLE_ROWS", 0)
elif instance.db_type in ("postgres", "postgresql"):
# Columns
col_sql = (
"SELECT column_name as Field, data_type as Type, "
"is_nullable as \"Null\", column_default as \"Default\", "
"'' as \"Key\", '' as Extra "
"FROM information_schema.columns "
"WHERE table_schema = 'public' "
"AND table_name = '" + table_name + "' "
"ORDER BY ordinal_position"
)
col_qr = await DBConnector.execute_query(instance, database, col_sql, user_id=0)
columns = col_qr.rows
# Indexes
idx_sql = (
"SELECT indexname as name, indexdef as definition "
"FROM pg_indexes "
"WHERE schemaname = 'public' AND tablename = '" + table_name + "'"
)
idx_qr = await DBConnector.execute_query(instance, database, idx_sql, user_id=0)
indexes = idx_qr.rows
# Create DDL — best effort via pg_dump not available; use a placeholder
create_ddl = "-- PostgreSQL DDL via pg_indexes:\n"
for idx in idx_qr.rows:
create_ddl += idx.get("definition", "") + ";\n"
# Row count
cnt_sql = "SELECT count(*) as cnt FROM \"" + table_name + "\""
try:
cnt_qr = await DBConnector.execute_query(instance, database, cnt_sql, user_id=0)
if cnt_qr.rows:
row_count = cnt_qr.rows[0].get("cnt", 0)
except Exception:
row_count = -1
elif instance.db_type == "sqlite":
# Columns
col_sql = "PRAGMA table_info('" + table_name + "')"
col_qr = await DBConnector.execute_query(instance, None, col_sql, user_id=0)
columns = []
for r in col_qr.rows:
columns.append({
"Field": r.get("name", ""),
"Type": r.get("type", ""),
"Null": "YES" if r.get("notnull", 0) == 0 else "NO",
"Key": "PRI" if r.get("pk", 0) == 1 else "",
"Default": r.get("dflt_value", ""),
"Extra": "",
})
# Indexes
idx_sql = "PRAGMA index_list('" + table_name + "')"
idx_qr = await DBConnector.execute_query(instance, None, idx_sql, user_id=0)
indexes = []
for r in idx_qr.rows:
indexes.append({
"name": r.get("name", ""),
"unique": r.get("unique", 0),
})
# Create DDL
ddl_sql = (
"SELECT sql FROM sqlite_master WHERE type='table' AND name='"
+ table_name + "'"
)
ddl_qr = await DBConnector.execute_query(instance, None, ddl_sql, user_id=0)
if ddl_qr.rows:
create_ddl = ddl_qr.rows[0].get("sql", "")
# Row count
cnt_sql = "SELECT count(*) as cnt FROM '" + table_name + "'"
try:
cnt_qr = await DBConnector.execute_query(instance, None, cnt_sql, user_id=0)
if cnt_qr.rows:
row_count = cnt_qr.rows[0].get("cnt", 0)
except Exception:
row_count = -1
return {
"columns": columns,
"indexes": indexes,
"create_ddl": create_ddl,
"row_count": row_count,
}
async def export_dictionary(
session: AsyncSession, instance_id: int, database: str
) -> List[Dict[str, Any]]:
"""导出整个数据库的表结构信息。"""
tables = await list_tables(session, instance_id, database)
result = []
for tbl in tables:
detail = await get_table_detail(session, instance_id, database, tbl)
result.append({
"table_name": tbl,
"columns": detail.get("columns", []),
"indexes": detail.get("indexes", []),
"create_ddl": detail.get("create_ddl", ""),
"row_count": detail.get("row_count", 0),
})
return result
+269
View File
@@ -0,0 +1,269 @@
"""Redis connector — async interface for Redis instance management.
Provides connection, info retrieval, key CRUD, command execution, and
performance monitoring via redis.asyncio.
"""
import json
from typing import Any, Dict, List, Optional, Tuple
import redis.asyncio as aioredis
from app.core.crypto import decrypt_password
class RedisConnector:
"""Async Redis connector for instance operations."""
@staticmethod
async def get_connection(instance) -> aioredis.Redis:
"""Create an async Redis connection from an Instance model."""
pwd = decrypt_password(instance.password)
return aioredis.Redis(
host=instance.host,
port=instance.port,
password=pwd or None,
decode_responses=True,
)
@staticmethod
async def get_info(instance) -> dict:
"""Get Redis INFO ALL parsed into a structured dict."""
conn = await RedisConnector.get_connection(instance)
try:
raw: str = await conn.info("all")
return raw
finally:
await conn.close()
@staticmethod
async def get_dbsize(instance, db: int = 0) -> int:
"""Get number of keys in a database."""
conn = await RedisConnector.get_connection(instance)
try:
await conn.select(db)
return await conn.dbsize()
finally:
await conn.close()
@staticmethod
async def scan_keys(
instance,
db: int = 0,
pattern: str = "*",
type_filter: Optional[str] = None,
cursor: int = 0,
count: int = 50,
) -> Tuple[int, List[Dict[str, Any]]]:
"""SCAN keys with optional type filter. Returns (next_cursor, key_details)."""
conn = await RedisConnector.get_connection(instance)
try:
await conn.select(db)
next_cursor, keys = await conn.scan(
cursor=cursor, match=pattern, count=count
)
result = []
for key in keys:
key_type = await conn.type(key)
if type_filter and key_type != type_filter:
continue
ttl = await conn.ttl(key)
result.append(
{"key": key, "type": key_type, "ttl": ttl}
)
return next_cursor, result
finally:
await conn.close()
@staticmethod
async def get_key_value(
instance, db: int, key: str, key_type: Optional[str] = None
) -> Any:
"""Get value for a key based on its type."""
conn = await RedisConnector.get_connection(instance)
try:
await conn.select(db)
if key_type is None:
key_type = await conn.type(key)
if key_type == "string":
value = await conn.get(key)
elif key_type == "hash":
value = await conn.hgetall(key)
elif key_type == "list":
value = await conn.lrange(key, 0, -1)
elif key_type == "set":
value = list(await conn.smembers(key))
elif key_type == "zset":
raw = await conn.zrange(key, 0, -1, withscores=True)
value = [{"member": m, "score": s} for m, s in raw]
else:
value = None
ttl = await conn.ttl(key)
return {"key": key, "type": key_type, "value": value, "ttl": ttl}
finally:
await conn.close()
@staticmethod
async def set_key_value(
instance,
db: int,
key: str,
value: Any,
type_: str = "string",
ttl: Optional[int] = None,
) -> str:
"""Set a key value. Type determines how value is stored."""
conn = await RedisConnector.get_connection(instance)
try:
await conn.select(db)
if type_ == "string":
await conn.set(key, value)
elif type_ == "hash":
# value should be a dict
if isinstance(value, str):
value = json.loads(value)
await conn.hset(key, mapping=value)
elif type_ == "list":
if isinstance(value, str):
value = json.loads(value)
await conn.delete(key)
if value:
await conn.rpush(key, *value)
elif type_ == "set":
if isinstance(value, str):
value = json.loads(value)
await conn.delete(key)
if value:
await conn.sadd(key, *value)
elif type_ == "zset":
if isinstance(value, str):
value = json.loads(value)
await conn.delete(key)
if isinstance(value, list):
for item in value:
member = item.get("member", "")
score = item.get("score", 0)
await conn.zadd(key, {member: score})
if ttl and ttl > 0:
await conn.expire(key, ttl)
return "OK"
finally:
await conn.close()
@staticmethod
async def del_key(instance, db: int, key: str) -> int:
"""Delete a key. Returns number of keys deleted."""
conn = await RedisConnector.get_connection(instance)
try:
await conn.select(db)
return await conn.delete(key)
finally:
await conn.close()
@staticmethod
async def set_ttl(instance, db: int, key: str, seconds: int) -> bool:
"""Set TTL on a key."""
conn = await RedisConnector.get_connection(instance)
try:
await conn.select(db)
return await conn.expire(key, seconds)
finally:
await conn.close()
@staticmethod
async def get_ttl(instance, db: int, key: str) -> int:
"""Get TTL of a key."""
conn = await RedisConnector.get_connection(instance)
try:
await conn.select(db)
return await conn.ttl(key)
finally:
await conn.close()
@staticmethod
async def execute_command(instance, db: int, command: str) -> str:
"""Execute a raw Redis command."""
conn = await RedisConnector.get_connection(instance)
try:
await conn.select(db)
parts = command.strip().split()
if not parts:
return ""
cmd = parts[0].upper()
args = parts[1:]
result = await conn.execute_command(cmd, *args)
if result is None:
return "(nil)"
if isinstance(result, bytes):
return result.decode("utf-8", errors="replace")
if isinstance(result, (list, set)):
formatted = []
for item in result:
if isinstance(item, bytes):
formatted.append(item.decode("utf-8", errors="replace"))
else:
formatted.append(str(item))
return "\n".join(formatted)
return str(result)
finally:
await conn.close()
@staticmethod
async def flush_db(instance, db: int = 0) -> str:
"""FLUSHDB — dangerous! Admin only."""
conn = await RedisConnector.get_connection(instance)
try:
await conn.select(db)
await conn.flushdb()
return "OK"
finally:
await conn.close()
@staticmethod
async def get_slowlog(instance, count: int = 10) -> List[dict]:
"""Get slowlog entries."""
conn = await RedisConnector.get_connection(instance)
try:
raw = await conn.slowlog_get(count)
result = []
for entry in raw:
result.append(
{
"id": entry.get("id", entry.get(0)),
"timestamp": entry.get("start_time", entry.get(1)),
"duration_us": entry.get("duration", entry.get(2)),
"command": " ".join(
str(c) if not isinstance(c, bytes) else c.decode("utf-8", errors="replace")
for c in entry.get("command", entry.get(3, []))
),
}
)
return result
finally:
await conn.close()
@staticmethod
async def get_clients(instance) -> List[dict]:
"""Get client list."""
conn = await RedisConnector.get_connection(instance)
try:
raw: str = await conn.client_list()
clients = []
for line in raw.split("\n"):
line = line.strip()
if not line:
continue
client = {}
for pair in line.split(" "):
if "=" in pair:
k, v = pair.split("=", 1)
client[k] = v
if client:
clients.append(client)
return clients
finally:
await conn.close()