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

+62
View File
@@ -0,0 +1,62 @@
"""Backup management API."""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import require_role
from app.models.user import User
from app.models.backup import Backup
from app.schemas.backup import BackupCreate, BackupResponse
router = APIRouter(prefix="/backups", tags=["备份管理"])
@router.get("", response_model=List[BackupResponse])
async def list_backups(
instance_id: Optional[int] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
stmt = select(Backup).order_by(Backup.id.desc())
if instance_id:
stmt = stmt.where(Backup.instance_id == instance_id)
result = await db.execute(stmt)
backups = result.scalars().all()
return backups
@router.post("", response_model=BackupResponse, status_code=201)
async def create_backup(
data: BackupCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
backup = Backup(
title=data.title,
instance_id=data.instance_id,
database=data.database,
filename=data.database + "_" + str(data.instance_id) + ".sql",
status="pending",
created_by=current_user.id,
remark=data.remark,
)
db.add(backup)
await db.commit()
await db.refresh(backup)
return backup
@router.get("/{backup_id}", response_model=BackupResponse)
async def get_backup(
backup_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
result = await db.execute(select(Backup).where(Backup.id == backup_id))
backup = result.scalar_one_or_none()
if not backup:
raise HTTPException(status_code=404, detail="备份记录不存在")
return backup
@@ -0,0 +1,70 @@
"""数据字典 API 端点。"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import require_role
from app.models.user import User
from app.services import dictionary_service
router = APIRouter(tags=["数据字典"])
@router.get("/instances/{instance_id}/databases")
async def list_databases(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba", "developer")),
):
"""获取实例的数据库列表。"""
databases = await dictionary_service.list_databases(db, instance_id)
if databases is None:
raise HTTPException(status_code=404, detail="数据源不存在")
return {"databases": databases}
@router.get("/instances/{instance_id}/tables")
async def list_tables(
instance_id: int,
database: str = Query(..., description="数据库名"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba", "developer")),
):
"""获取指定数据库的表列表。"""
tables = await dictionary_service.list_tables(db, instance_id, database)
if tables is None:
raise HTTPException(status_code=404, detail="数据源不存在")
return {"tables": tables}
@router.get("/instances/{instance_id}/tables/{table_name}")
async def get_table_detail(
instance_id: int,
table_name: str,
database: str = Query(..., description="数据库名"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba", "developer")),
):
"""获取表详情: 字段/索引/DDL/行数。"""
detail = await dictionary_service.get_table_detail(
db, instance_id, database, table_name
)
if not detail:
raise HTTPException(status_code=404, detail="数据源不存在")
return detail
@router.get("/instances/{instance_id}/export")
async def export_dictionary(
instance_id: int,
database: str = Query(..., description="数据库名"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba", "developer")),
):
"""导出整个数据库的表结构信息。"""
result = await dictionary_service.export_dictionary(
db, instance_id, database
)
return {"tables": result}
@@ -0,0 +1,331 @@
"""MySQL instance detail management API — databases, tables, users, slow queries, processlist, status, variables."""
import asyncio
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import require_role
from app.models.user import User
from app.models.instance import Instance
from app.core.crypto import decrypt_password
router = APIRouter(prefix="/instances", tags=["实例详情"])
def _get_instance_or_404(db: AsyncSession, instance_id: int):
"""Helper to get instance or raise 404. Returns coroutine."""
return db.execute(select(Instance).where(Instance.id == instance_id))
async def _get_decrypted_instance(db: AsyncSession, instance_id: int) -> Instance:
result = await _get_instance_or_404(db, instance_id)
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在")
instance.password = decrypt_password(instance.password)
return instance
async def _mysql_query(instance: Instance, sql: str, database: Optional[str] = None) -> list:
"""Execute a MySQL query and return rows as list of dicts."""
import aiomysql
conn = await aiomysql.connect(
host=instance.host, port=instance.port,
user=instance.username, password=instance.password,
db=database or instance.database,
)
try:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(sql)
rows = await cur.fetchall()
return rows
finally:
conn.close()
async def _mysql_execute(instance: Instance, sql: str, database: Optional[str] = None):
"""Execute a MySQL statement (no result set needed)."""
import aiomysql
conn = await aiomysql.connect(
host=instance.host, port=instance.port,
user=instance.username, password=instance.password,
db=database or instance.database,
)
try:
async with conn.cursor() as cur:
await cur.execute(sql)
finally:
conn.close()
# ---- Databases ----
@router.get("/{instance_id}/detail/databases")
async def list_databases(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
instance = await _get_decrypted_instance(db, instance_id)
if instance.db_type != "mysql":
raise HTTPException(status_code=400, detail="仅支持MySQL实例")
try:
rows = await _mysql_query(instance, "SHOW DATABASES")
databases = [
row["Database"] for row in rows
if row["Database"] not in ("information_schema", "performance_schema", "sys", "mysql")
]
return databases
except Exception as e:
raise HTTPException(status_code=500, detail="查询数据库列表失败: " + str(e))
# ---- Tables ----
@router.get("/{instance_id}/detail/tables")
async def list_tables(
instance_id: int,
database: str = Query(..., description="数据库名"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
instance = await _get_decrypted_instance(db, instance_id)
if instance.db_type != "mysql":
raise HTTPException(status_code=400, detail="仅支持MySQL实例")
try:
rows = await _mysql_query(instance, "SHOW TABLE STATUS", database=database)
tables = []
for row in rows:
tables.append({
"name": row.get("Name", ""),
"engine": row.get("Engine", ""),
"rows": row.get("Rows", 0),
"data_length": row.get("Data_length", 0),
"index_length": row.get("Index_length", 0),
"comment": row.get("Comment", ""),
"collation": row.get("Collation", ""),
"create_time": str(row.get("Create_time", "")),
"update_time": str(row.get("Update_time", "")),
})
return tables
except Exception as e:
raise HTTPException(status_code=500, detail="查询表列表失败: " + str(e))
# ---- Table Detail ----
@router.get("/{instance_id}/detail/table-detail")
async def table_detail(
instance_id: int,
database: str = Query(..., description="数据库名"),
table: str = Query(..., description="表名"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
instance = await _get_decrypted_instance(db, instance_id)
if instance.db_type != "mysql":
raise HTTPException(status_code=400, detail="仅支持MySQL实例")
try:
# SHOW CREATE TABLE
ddl_rows = await _mysql_query(
instance,
"SHOW CREATE TABLE " + table,
database=database,
)
ddl = ddl_rows[0].get("Create Table", "") if ddl_rows else ""
# SHOW COLUMNS
columns = await _mysql_query(
instance,
"SHOW COLUMNS FROM " + table,
database=database,
)
# SHOW INDEX
indexes = await _mysql_query(
instance,
"SHOW INDEX FROM " + table,
database=database,
)
return {
"ddl": ddl,
"columns": columns,
"indexes": indexes,
}
except Exception as e:
raise HTTPException(status_code=500, detail="查询表详情失败: " + str(e))
# ---- Users ----
@router.get("/{instance_id}/detail/users")
async def list_users(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
instance = await _get_decrypted_instance(db, instance_id)
if instance.db_type != "mysql":
raise HTTPException(status_code=400, detail="仅支持MySQL实例")
try:
rows = await _mysql_query(instance, "SELECT user, host FROM mysql.user")
users = [{"user": row.get("user", ""), "host": row.get("host", "")} for row in rows]
return users
except Exception as e:
raise HTTPException(status_code=500, detail="查询用户列表失败: " + str(e))
# ---- Grants ----
@router.get("/{instance_id}/detail/grants")
async def show_grants(
instance_id: int,
user: str = Query(..., description="用户名"),
host: str = Query(..., description="主机"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
instance = await _get_decrypted_instance(db, instance_id)
if instance.db_type != "mysql":
raise HTTPException(status_code=400, detail="仅支持MySQL实例")
try:
quoted_user = user.replace("'", "\\'")
quoted_host = host.replace("'", "\\'")
sql = "SHOW GRANTS FOR '" + quoted_user + "'@'" + quoted_host + "'"
rows = await _mysql_query(instance, sql)
grants = []
for row in rows:
# The key varies by MySQL version, grab first value
grants.append(list(row.values())[0])
return {"user": user, "host": host, "grants": grants}
except Exception as e:
raise HTTPException(status_code=500, detail="查询权限失败: " + str(e))
# ---- Slow Queries ----
@router.get("/{instance_id}/detail/slow-queries")
async def slow_queries(
instance_id: int,
database: Optional[str] = Query(None, description="数据库名"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
instance = await _get_decrypted_instance(db, instance_id)
if instance.db_type != "mysql":
raise HTTPException(status_code=400, detail="仅支持MySQL实例")
try:
# Try querying mysql.slow_log table first
try:
sql = (
"SELECT start_time, user_host, query_time, lock_time, "
"rows_sent, rows_examined, sql_text "
"FROM mysql.slow_log ORDER BY start_time DESC LIMIT 100"
)
rows = await _mysql_query(instance, sql, database=database)
return rows
except Exception:
# Fallback: show slow query variables
rows = await _mysql_query(
instance,
"SHOW VARIABLES LIKE 'slow%'",
database=database,
)
return rows
except Exception as e:
raise HTTPException(status_code=500, detail="查询慢查询失败: " + str(e))
# ---- Processlist ----
@router.get("/{instance_id}/detail/processlist")
async def show_processlist(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
instance = await _get_decrypted_instance(db, instance_id)
if instance.db_type != "mysql":
raise HTTPException(status_code=400, detail="仅支持MySQL实例")
try:
rows = await _mysql_query(instance, "SHOW FULL PROCESSLIST")
return rows
except Exception as e:
raise HTTPException(status_code=500, detail="查询进程列表失败: " + str(e))
# ---- Kill Process ----
@router.post("/{instance_id}/detail/processlist/{pid}/kill")
async def kill_process(
instance_id: int,
pid: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
instance = await _get_decrypted_instance(db, instance_id)
if instance.db_type != "mysql":
raise HTTPException(status_code=400, detail="仅支持MySQL实例")
try:
await _mysql_execute(instance, "KILL CONNECTION " + str(pid))
return {"message": "进程已终止", "pid": pid}
except Exception as e:
raise HTTPException(status_code=500, detail="终止进程失败: " + str(e))
# ---- Status ----
@router.get("/{instance_id}/detail/status")
async def show_status(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
instance = await _get_decrypted_instance(db, instance_id)
if instance.db_type != "mysql":
raise HTTPException(status_code=400, detail="仅支持MySQL实例")
try:
rows = await _mysql_query(instance, "SHOW GLOBAL STATUS")
# Return key metrics
key_metrics = {}
important_keys = {
"Threads_connected", "Threads_running", "Threads_cached",
"Queries", "Questions", "Slow_queries",
"Connections", "Aborted_connects",
"Bytes_received", "Bytes_sent",
"Table_open_cache_hits", "Table_open_cache_misses",
"Innodb_row_lock_waits", "Innodb_row_lock_time",
"Uptime",
}
for row in rows:
var_name = row.get("Variable_name", "")
if var_name in important_keys:
key_metrics[var_name] = row.get("Value", "")
return {
"key_metrics": key_metrics,
"all_status": rows,
}
except Exception as e:
raise HTTPException(status_code=500, detail="查询状态失败: " + str(e))
# ---- Variables ----
@router.get("/{instance_id}/detail/variables")
async def show_variables(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
instance = await _get_decrypted_instance(db, instance_id)
if instance.db_type != "mysql":
raise HTTPException(status_code=400, detail="仅支持MySQL实例")
try:
rows = await _mysql_query(instance, "SHOW VARIABLES")
return rows
except Exception as e:
raise HTTPException(status_code=500, detail="查询变量失败: " + str(e))
+240
View File
@@ -0,0 +1,240 @@
"""Redis management API endpoints."""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user, require_role
from app.models.user import User
from app.models.instance import Instance
from app.services import redis_connector
router = APIRouter(tags=["Redis管理"])
async def _get_redis_instance(instance_id: int, db: AsyncSession) -> Instance:
"""Fetch and validate a Redis instance."""
result = await db.execute(select(Instance).where(Instance.id == instance_id))
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在")
if instance.db_type != "redis":
raise HTTPException(status_code=400, detail="该实例不是 Redis 类型")
return instance
# ── Request schemas ──────────────────────────────────────────────
class RedisKeyCreate(BaseModel):
db: int = 0
key: str
value: object
type: str = "string"
ttl: Optional[int] = None
class RedisCommandRequest(BaseModel):
db: int = 0
command: str
class RedisExpireRequest(BaseModel):
db: int = 0
seconds: int
# ── Endpoints ────────────────────────────────────────────────────
@router.get("/redis/{instance_id}/info")
async def get_redis_info(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Redis 信息概览."""
instance = await _get_redis_instance(instance_id, db)
try:
info = await redis_connector.RedisConnector.get_info(instance)
return info
except Exception as e:
raise HTTPException(status_code=500, detail="获取 Redis 信息失败: " + str(e))
@router.get("/redis/{instance_id}/dbsize")
async def get_redis_dbsize(
instance_id: int,
db_num: int = Query(0, alias="db"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""DBSIZE."""
instance = await _get_redis_instance(instance_id, db)
try:
size = await redis_connector.RedisConnector.get_dbsize(instance, db_num)
return {"db": db_num, "size": size}
except Exception as e:
raise HTTPException(status_code=500, detail="获取 DBSIZE 失败: " + str(e))
@router.get("/redis/{instance_id}/keys")
async def get_redis_keys(
instance_id: int,
db_num: int = Query(0, alias="db"),
pattern: str = Query("*"),
type: Optional[str] = Query(None, alias="type"),
cursor: int = Query(0),
count: int = Query(50),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""键列表."""
instance = await _get_redis_instance(instance_id, db)
try:
next_cursor, keys = await redis_connector.RedisConnector.scan_keys(
instance, db=db_num, pattern=pattern,
type_filter=type, cursor=cursor, count=count,
)
return {"cursor": next_cursor, "keys": keys}
except Exception as e:
raise HTTPException(status_code=500, detail="获取键列表失败: " + str(e))
@router.get("/redis/{instance_id}/keys/{key:path}")
async def get_redis_key_detail(
instance_id: int,
key: str,
db_num: int = Query(0, alias="db"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""键详情."""
instance = await _get_redis_instance(instance_id, db)
try:
detail = await redis_connector.RedisConnector.get_key_value(
instance, db_num, key
)
return detail
except Exception as e:
raise HTTPException(status_code=500, detail="获取键详情失败: " + str(e))
@router.post("/redis/{instance_id}/keys")
async def create_redis_key(
instance_id: int,
data: RedisKeyCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""创建/更新键."""
instance = await _get_redis_instance(instance_id, db)
try:
result = await redis_connector.RedisConnector.set_key_value(
instance, data.db, data.key, data.value, data.type, data.ttl
)
return {"message": "操作成功", "result": result}
except Exception as e:
raise HTTPException(status_code=500, detail="创建键失败: " + str(e))
@router.delete("/redis/{instance_id}/keys/{key:path}")
async def delete_redis_key(
instance_id: int,
key: str,
db_num: int = Query(0, alias="db"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""删除键."""
instance = await _get_redis_instance(instance_id, db)
try:
deleted = await redis_connector.RedisConnector.del_key(instance, db_num, key)
return {"message": "删除成功", "deleted": deleted}
except Exception as e:
raise HTTPException(status_code=500, detail="删除键失败: " + str(e))
@router.put("/redis/{instance_id}/keys/{key:path}/expire")
async def set_redis_key_expire(
instance_id: int,
key: str,
data: RedisExpireRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""设置 TTL."""
instance = await _get_redis_instance(instance_id, db)
try:
ok = await redis_connector.RedisConnector.set_ttl(
instance, data.db, key, data.seconds
)
return {"message": "设置成功" if ok else "设置失败"}
except Exception as e:
raise HTTPException(status_code=500, detail="设置 TTL 失败: " + str(e))
@router.delete("/redis/{instance_id}/flushdb")
async def flush_redis_db(
instance_id: int,
db_num: int = Query(0, alias="db"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""清空DB (admin only!)."""
instance = await _get_redis_instance(instance_id, db)
try:
result = await redis_connector.RedisConnector.flush_db(instance, db_num)
return {"message": "清空成功", "result": result}
except Exception as e:
raise HTTPException(status_code=500, detail="清空 DB 失败: " + str(e))
@router.post("/redis/{instance_id}/command")
async def execute_redis_command(
instance_id: int,
data: RedisCommandRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""执行命令."""
instance = await _get_redis_instance(instance_id, db)
try:
output = await redis_connector.RedisConnector.execute_command(
instance, data.db, data.command
)
return {"output": output}
except Exception as e:
raise HTTPException(status_code=500, detail="命令执行失败: " + str(e))
@router.get("/redis/{instance_id}/slowlog")
async def get_redis_slowlog(
instance_id: int,
count: int = Query(10),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""慢日志."""
instance = await _get_redis_instance(instance_id, db)
try:
logs = await redis_connector.RedisConnector.get_slowlog(instance, count)
return logs
except Exception as e:
raise HTTPException(status_code=500, detail="获取慢日志失败: " + str(e))
@router.get("/redis/{instance_id}/clients")
async def get_redis_clients(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""客户端列表."""
instance = await _get_redis_instance(instance_id, db)
try:
clients = await redis_connector.RedisConnector.get_clients(instance)
return clients
except Exception as e:
raise HTTPException(status_code=500, detail="获取客户端列表失败: " + str(e))
+5
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter
from app.api.v1.endpoints import (
auth, user, instance, ticket, query, dashboard, audit, audit_rule,
announcement, notification, ldap_config,
instance_detail, backup, redis, dictionary,
)
api_router = APIRouter(prefix="/api/v1")
@@ -18,3 +19,7 @@ api_router.include_router(audit_rule.router)
api_router.include_router(announcement.router)
api_router.include_router(notification.router)
api_router.include_router(ldap_config.router)
api_router.include_router(instance_detail.router)
api_router.include_router(backup.router)
api_router.include_router(redis.router)
api_router.include_router(dictionary.router, prefix="/dictionary", tags=["dictionary"])