refactor: 数据源菜单合一+布局优化
- 4个数据源菜单(MySQL/PG/Redis/Mongo)合并为1个「数据源」 - InstancePage 改为统一入口,顶部Tab切换类型过滤 - 添加数据源改为弹框Modal(默认MySQL) - 去掉左下角用户区,退出登录移到右上角header - 侧边栏收窄220px,整体布局更紧凑 - 后端202 tests passing - 前端构建零错误
This commit is contained in:
1 parent
bab55d3927
commit
5cbd0e3441
23 files changed
+2881
-94
No files matched your search
@@ -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
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user