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 ( from app.api.v1.endpoints import (
auth, user, instance, ticket, query, dashboard, audit, audit_rule, auth, user, instance, ticket, query, dashboard, audit, audit_rule,
announcement, notification, ldap_config, announcement, notification, ldap_config,
instance_detail, backup, redis, dictionary,
) )
api_router = APIRouter(prefix="/api/v1") 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(announcement.router)
api_router.include_router(notification.router) api_router.include_router(notification.router)
api_router.include_router(ldap_config.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"])
+1
View File
@@ -52,6 +52,7 @@ async def lifespan(app: FastAPI):
import app.models.announcement # noqa import app.models.announcement # noqa
import app.models.notification # noqa import app.models.notification # noqa
import app.models.ldap_config # noqa import app.models.ldap_config # noqa
import app.models.backup # noqa
await init_db() await init_db()
await seed_data() await seed_data()
+19
View File
@@ -0,0 +1,19 @@
from sqlalchemy import Column, Integer, String, BigInteger, Text, DateTime, func
from app.core.database import Base
class Backup(Base):
__tablename__ = "backups"
id = Column(Integer, primary_key=True, autoincrement=True)
instance_id = Column(Integer, nullable=False)
title = Column(String(200), nullable=False)
database = Column(String(100), nullable=False)
filename = Column(String(500), nullable=False, default="")
size_bytes = Column(BigInteger, default=0)
status = Column(String(20), default="pending") # pending / running / completed / failed
created_by = Column(Integer, nullable=True)
created_at = Column(DateTime, server_default=func.now())
completed_at = Column(DateTime, nullable=True)
remark = Column(Text, nullable=True)
+28
View File
@@ -0,0 +1,28 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class BackupCreate(BaseModel):
title: str
instance_id: int
database: str
remark: Optional[str] = None
class BackupResponse(BaseModel):
id: int
title: str
instance_id: int
database: str
filename: str
size_bytes: int
status: str
created_by: Optional[int] = None
created_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
remark: Optional[str] = None
class Config:
from_attributes = True
+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()
+1
View File
@@ -15,6 +15,7 @@ from app.models.audit_rule import AuditRule
from app.models.announcement import Announcement from app.models.announcement import Announcement
from app.models.notification import NotificationChannel from app.models.notification import NotificationChannel
from app.models.ldap_config import LdapConfig from app.models.ldap_config import LdapConfig
from app.models.backup import Backup
SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite://" SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite://"
+227
View File
@@ -0,0 +1,227 @@
"""数据字典模块测试。"""
import sys
from unittest.mock import patch, AsyncMock, MagicMock
from httpx import AsyncClient
from tests.conftest import auth_header
from app.services.db_connector import QueryResult
def _mock_query_result(columns, rows):
"""Helper: build a QueryResult for mocking execute_query."""
return QueryResult(
columns=columns,
rows=rows,
total=len(rows),
elapsed_ms=10,
truncated=False,
)
class TestListDatabases:
async def test_list_databases_success(
self, client: AsyncClient, test_instance, admin_token
):
mock_result = _mock_query_result(
["Database"],
[
{"Database": "test_db"},
{"Database": "app_db"},
{"Database": "information_schema"},
],
)
with patch(
"app.services.dictionary_service.DBConnector.execute_query",
new_callable=AsyncMock,
return_value=mock_result,
):
resp = await client.get(
"/api/v1/dictionary/instances/" + str(test_instance.id) + "/databases",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert "databases" in data
db_list = data["databases"]
assert "test_db" in db_list
assert "app_db" in db_list
# system databases should be filtered
assert "information_schema" not in db_list
async def test_list_databases_not_found(
self, client: AsyncClient, admin_token
):
resp = await client.get(
"/api/v1/dictionary/instances/99999/databases",
headers=auth_header(admin_token),
)
assert resp.status_code == 404
class TestListTables:
async def test_list_tables_success(
self, client: AsyncClient, test_instance, admin_token
):
mock_result = _mock_query_result(
["TABLE_NAME"],
[{"TABLE_NAME": "users"}, {"TABLE_NAME": "orders"}],
)
with patch(
"app.services.dictionary_service.DBConnector.execute_query",
new_callable=AsyncMock,
return_value=mock_result,
):
resp = await client.get(
"/api/v1/dictionary/instances/" + str(test_instance.id) + "/tables",
params={"database": "test_db"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert "tables" in data
assert "users" in data["tables"]
assert "orders" in data["tables"]
class TestGetTableDetail:
async def test_table_detail_success(
self, client: AsyncClient, test_instance, admin_token
):
col_result = _mock_query_result(
["Field", "Type", "Null", "Key", "Default", "Extra", "Comment"],
[
{
"Field": "id",
"Type": "int",
"Null": "NO",
"Key": "PRI",
"Default": None,
"Extra": "auto_increment",
"Comment": "primary key",
},
],
)
idx_result = _mock_query_result(
["Key_name", "Non_unique", "Index_type", "Column_name"],
[
{"Key_name": "PRIMARY", "Non_unique": 0, "Index_type": "BTREE", "Column_name": "id"},
],
)
ddl_result = _mock_query_result(
["Table", "Create Table"],
[{"Table": "users", "Create Table": "CREATE TABLE users (id int PRIMARY KEY)"}],
)
cnt_result = _mock_query_result(
["TABLE_ROWS"],
[{"TABLE_ROWS": 42}],
)
call_count = 0
results = [col_result, idx_result, ddl_result, cnt_result]
async def _mock_execute(*args, **kwargs):
nonlocal call_count
r = results[call_count]
call_count += 1
return r
with patch(
"app.services.dictionary_service.DBConnector.execute_query",
new=AsyncMock(side_effect=_mock_execute),
):
resp = await client.get(
"/api/v1/dictionary/instances/" + str(test_instance.id) + "/tables/users",
params={"database": "test_db"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert "columns" in data
assert len(data["columns"]) == 1
assert data["columns"][0]["Field"] == "id"
assert "indexes" in data
assert "create_ddl" in data
assert "row_count" in data
assert data["row_count"] == 42
class TestExportDictionary:
async def test_export_success(
self, client: AsyncClient, test_instance, admin_token
):
# list_tables returns 1 table
tbl_result = _mock_query_result(
["TABLE_NAME"],
[{"TABLE_NAME": "users"}],
)
# get_table_detail: columns
col_result = _mock_query_result(
["Field", "Type", "Null", "Key", "Default", "Extra", "Comment"],
[{"Field": "id", "Type": "int", "Null": "NO", "Key": "PRI", "Default": None, "Extra": "", "Comment": ""}],
)
# indexes
idx_result = _mock_query_result(
["Key_name", "Non_unique", "Index_type", "Column_name"],
[{"Key_name": "PRIMARY", "Non_unique": 0, "Index_type": "BTREE", "Column_name": "id"}],
)
# ddl
ddl_result = _mock_query_result(
["Table", "Create Table"],
[{"Table": "users", "Create Table": "CREATE TABLE users (id int)"}],
)
# row count
cnt_result = _mock_query_result(
["TABLE_ROWS"],
[{"TABLE_ROWS": 10}],
)
results = [tbl_result, col_result, idx_result, ddl_result, cnt_result]
call_count = 0
async def _mock_execute(*args, **kwargs):
nonlocal call_count
r = results[call_count]
call_count += 1
return r
with patch(
"app.services.dictionary_service.DBConnector.execute_query",
new=AsyncMock(side_effect=_mock_execute),
):
resp = await client.get(
"/api/v1/dictionary/instances/" + str(test_instance.id) + "/export",
params={"database": "test_db"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert "tables" in data
assert len(data["tables"]) == 1
assert data["tables"][0]["table_name"] == "users"
class TestPermission:
async def test_guest_forbidden(
self, client: AsyncClient, test_instance, guest_token
):
resp = await client.get(
"/api/v1/dictionary/instances/" + str(test_instance.id) + "/databases",
headers=auth_header(guest_token),
)
assert resp.status_code == 403
async def test_developer_allowed(
self, client: AsyncClient, test_instance, developer_token
):
mock_result = _mock_query_result(["Database"], [])
with patch(
"app.services.dictionary_service.DBConnector.execute_query",
new_callable=AsyncMock,
return_value=mock_result,
):
resp = await client.get(
"/api/v1/dictionary/instances/" + str(test_instance.id) + "/databases",
headers=auth_header(developer_token),
)
assert resp.status_code == 200
+365
View File
@@ -0,0 +1,365 @@
"""Tests for instance detail API endpoints and backup API."""
import sys
from unittest.mock import patch, AsyncMock, MagicMock
from contextlib import asynccontextmanager
import pytest
from httpx import AsyncClient
from tests.conftest import auth_header
def _make_aiomysql_mock():
"""Create a mock aiomysql module."""
aiomysql = MagicMock()
aiomysql.DictCursor = MagicMock()
return aiomysql
def _mock_mysql_connection(rows=None, description=None):
"""Create a mock MySQL connection that returns given rows."""
if rows is None:
rows = []
mock_cursor = AsyncMock()
mock_cursor.execute = AsyncMock()
mock_cursor.fetchall = AsyncMock(return_value=rows)
mock_cursor.description = description
@asynccontextmanager
async def mock_cursor_ctx(*args, **kwargs):
yield mock_cursor
mock_conn = AsyncMock()
mock_conn.cursor = mock_cursor_ctx
mock_conn.close = MagicMock()
return mock_conn, mock_cursor
class TestListDatabases:
async def test_list_databases_success(self, client: AsyncClient, test_instance, admin_token):
mock_conn, _ = _mock_mysql_connection([
{"Database": "test_db"},
{"Database": "myapp"},
{"Database": "information_schema"},
])
mock_aiomysql = _make_aiomysql_mock()
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
resp = await client.get(
"/api/v1/instances/" + str(test_instance.id) + "/detail/databases",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert "test_db" in data
assert "myapp" in data
# information_schema should be filtered out
assert "information_schema" not in data
async def test_list_databases_requires_admin(self, client: AsyncClient, test_instance, developer_token):
resp = await client.get(
"/api/v1/instances/" + str(test_instance.id) + "/detail/databases",
headers=auth_header(developer_token),
)
assert resp.status_code == 403
class TestListTables:
async def test_list_tables_success(self, client: AsyncClient, test_instance, admin_token):
mock_conn, _ = _mock_mysql_connection([
{"Name": "users", "Engine": "InnoDB", "Rows": 100, "Data_length": 16384,
"Index_length": 0, "Comment": "", "Collation": "utf8mb4_general_ci",
"Create_time": "2024-01-01", "Update_time": None},
])
mock_aiomysql = _make_aiomysql_mock()
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
resp = await client.get(
"/api/v1/instances/" + str(test_instance.id) + "/detail/tables",
params={"database": "test_db"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["name"] == "users"
assert data[0]["engine"] == "InnoDB"
assert data[0]["rows"] == 100
class TestTableDetail:
async def test_table_detail_success(self, client: AsyncClient, test_instance, admin_token):
# Need three separate connections for DDL, columns, indexes
ddl_conn, _ = _mock_mysql_connection([
{"Table": "users", "Create Table": "CREATE TABLE users (id INT PRIMARY KEY)"},
])
col_conn, _ = _mock_mysql_connection([
{"Field": "id", "Type": "int", "Null": "NO", "Key": "PRI", "Default": None, "Extra": "auto_increment"},
])
idx_conn, _ = _mock_mysql_connection([
{"Table": "users", "Non_unique": 0, "Key_name": "PRIMARY", "Column_name": "id"},
])
call_count = 0
async def mock_connect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return ddl_conn
elif call_count == 2:
return col_conn
else:
return idx_conn
mock_aiomysql = _make_aiomysql_mock()
mock_aiomysql.connect = mock_connect
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
resp = await client.get(
"/api/v1/instances/" + str(test_instance.id) + "/detail/table-detail",
params={"database": "test_db", "table": "users"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert "ddl" in data
assert "columns" in data
assert "indexes" in data
assert "CREATE TABLE" in data["ddl"]
class TestListUsers:
async def test_list_users_success(self, client: AsyncClient, test_instance, admin_token):
mock_conn, _ = _mock_mysql_connection([
{"user": "root", "host": "localhost"},
{"user": "admin", "host": "%"},
])
mock_aiomysql = _make_aiomysql_mock()
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
resp = await client.get(
"/api/v1/instances/" + str(test_instance.id) + "/detail/users",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert data[0]["user"] == "root"
class TestShowGrants:
async def test_show_grants_success(self, client: AsyncClient, test_instance, admin_token):
mock_conn, _ = _mock_mysql_connection([
{"Grants for root@localhost": "GRANT ALL PRIVILEGES ON *.* TO 'root'@'localhost'"},
])
mock_aiomysql = _make_aiomysql_mock()
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
resp = await client.get(
"/api/v1/instances/" + str(test_instance.id) + "/detail/grants",
params={"user": "root", "host": "localhost"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["user"] == "root"
assert len(data["grants"]) >= 1
class TestSlowQueries:
async def test_slow_queries_success(self, client: AsyncClient, test_instance, admin_token):
mock_conn, _ = _mock_mysql_connection([
{"start_time": "2024-01-01 00:00:00", "user_host": "root[root]@localhost",
"query_time": "00:00:05", "lock_time": "00:00:00", "rows_sent": 1,
"rows_examined": 1000, "sql_text": "SELECT * FROM big_table"},
])
mock_aiomysql = _make_aiomysql_mock()
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
resp = await client.get(
"/api/v1/instances/" + str(test_instance.id) + "/detail/slow-queries",
params={"database": "test_db"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
class TestProcesslist:
async def test_show_processlist_success(self, client: AsyncClient, test_instance, admin_token):
mock_conn, _ = _mock_mysql_connection([
{"Id": 1, "User": "root", "Host": "localhost", "db": "test_db",
"Command": "Query", "Time": 10, "State": "executing", "Info": "SELECT 1"},
])
mock_aiomysql = _make_aiomysql_mock()
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
resp = await client.get(
"/api/v1/instances/" + str(test_instance.id) + "/detail/processlist",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["Id"] == 1
class TestKillProcess:
async def test_kill_process_success(self, client: AsyncClient, test_instance, admin_token):
mock_conn, _ = _mock_mysql_connection([])
mock_aiomysql = _make_aiomysql_mock()
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
resp = await client.post(
"/api/v1/instances/" + str(test_instance.id) + "/detail/processlist/123/kill",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["pid"] == 123
assert "已终止" in data["message"]
class TestShowStatus:
async def test_show_status_success(self, client: AsyncClient, test_instance, admin_token):
mock_conn, _ = _mock_mysql_connection([
{"Variable_name": "Threads_connected", "Value": "5"},
{"Variable_name": "Queries", "Value": "1000"},
{"Variable_name": "Slow_queries", "Value": "10"},
{"Variable_name": "Uptime", "Value": "86400"},
])
mock_aiomysql = _make_aiomysql_mock()
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
resp = await client.get(
"/api/v1/instances/" + str(test_instance.id) + "/detail/status",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert "key_metrics" in data
assert data["key_metrics"]["Threads_connected"] == "5"
class TestShowVariables:
async def test_show_variables_success(self, client: AsyncClient, test_instance, admin_token):
mock_conn, _ = _mock_mysql_connection([
{"Variable_name": "max_connections", "Value": "151"},
{"Variable_name": "slow_query_log", "Value": "ON"},
])
mock_aiomysql = _make_aiomysql_mock()
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
resp = await client.get(
"/api/v1/instances/" + str(test_instance.id) + "/detail/variables",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
class TestBackupAPI:
async def test_create_backup(self, client: AsyncClient, test_instance, admin_token):
resp = await client.post(
"/api/v1/backups",
headers=auth_header(admin_token),
json={
"title": "测试备份",
"instance_id": test_instance.id,
"database": "test_db",
"remark": "自动备份",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["title"] == "测试备份"
assert data["status"] == "pending"
assert data["database"] == "test_db"
async def test_list_backups(self, client: AsyncClient, test_instance, admin_token):
# Create a backup first
await client.post(
"/api/v1/backups",
headers=auth_header(admin_token),
json={
"title": "备份1",
"instance_id": test_instance.id,
"database": "test_db",
},
)
resp = await client.get(
"/api/v1/backups",
params={"instance_id": test_instance.id},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 1
async def test_get_backup_detail(self, client: AsyncClient, test_instance, admin_token):
# Create a backup
create_resp = await client.post(
"/api/v1/backups",
headers=auth_header(admin_token),
json={
"title": "备份详情测试",
"instance_id": test_instance.id,
"database": "test_db",
},
)
backup_id = create_resp.json()["id"]
resp = await client.get(
"/api/v1/backups/" + str(backup_id),
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["id"] == backup_id
assert data["title"] == "备份详情测试"
async def test_get_backup_not_found(self, client: AsyncClient, admin_token):
resp = await client.get(
"/api/v1/backups/99999",
headers=auth_header(admin_token),
)
assert resp.status_code == 404
async def test_backup_requires_admin(self, client: AsyncClient, developer_token):
resp = await client.get(
"/api/v1/backups",
headers=auth_header(developer_token),
)
assert resp.status_code == 403
class TestInstanceNotFound:
async def test_databases_instance_not_found(self, client: AsyncClient, admin_token):
resp = await client.get(
"/api/v1/instances/99999/detail/databases",
headers=auth_header(admin_token),
)
assert resp.status_code == 404
+296
View File
@@ -0,0 +1,296 @@
"""Tests for Redis management API endpoints — all methods mocked."""
import pytest
from unittest.mock import AsyncMock, patch
from tests.conftest import auth_header
# ── Helpers ──────────────────────────────────────────────────────
REDIS_INSTANCE_DATA = {
"name": "Test Redis",
"db_type": "redis",
"host": "127.0.0.1",
"port": 6379,
"username": "default",
"password": "test123",
"database": "0",
"env": "development",
"description": "Test Redis instance",
}
MOCK_INFO = {
"redis_version": "7.2.3",
"uptime_in_days": 30,
"connected_clients": 42,
"db0": {"keys": 1000, "expires": 500},
"used_memory_human": "256M",
"keyspace_hits": 10000,
"keyspace_misses": 100,
}
async def _create_redis_instance(client, admin_token):
"""Create a Redis instance and return its ID."""
resp = await client.post(
"/api/v1/instances",
json=REDIS_INSTANCE_DATA,
headers=auth_header(admin_token),
)
assert resp.status_code == 201
return resp.json()["id"]
# ── Info endpoint ────────────────────────────────────────────────
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
async def test_get_redis_info(mock_cls, client, admin_token):
mock_cls.get_info = AsyncMock(return_value=MOCK_INFO)
iid = await _create_redis_instance(client, admin_token)
resp = await client.get(
"/api/v1/redis/" + str(iid) + "/info",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["redis_version"] == "7.2.3"
# ── DBSIZE endpoint ──────────────────────────────────────────────
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
async def test_get_redis_dbsize(mock_cls, client, admin_token):
mock_cls.get_dbsize = AsyncMock(return_value=1000)
iid = await _create_redis_instance(client, admin_token)
resp = await client.get(
"/api/v1/redis/" + str(iid) + "/dbsize?db=0",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["size"] == 1000
# ── Keys list ────────────────────────────────────────────────────
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
async def test_get_redis_keys(mock_cls, client, admin_token):
mock_keys = [
{"key": "user:1", "type": "string", "ttl": 3600},
{"key": "session:abc", "type": "hash", "ttl": -1},
]
mock_cls.scan_keys = AsyncMock(return_value=(0, mock_keys))
iid = await _create_redis_instance(client, admin_token)
resp = await client.get(
"/api/v1/redis/" + str(iid) + "/keys?db=0&pattern=*&count=50",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert len(data["keys"]) == 2
assert data["keys"][0]["key"] == "user:1"
# ── Key detail ───────────────────────────────────────────────────
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
async def test_get_redis_key_detail(mock_cls, client, admin_token):
mock_cls.get_key_value = AsyncMock(return_value={
"key": "user:1", "type": "string", "value": "hello", "ttl": 3600,
})
iid = await _create_redis_instance(client, admin_token)
resp = await client.get(
"/api/v1/redis/" + str(iid) + "/keys/user:1?db=0",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["value"] == "hello"
# ── Key CRUD ─────────────────────────────────────────────────────
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
async def test_create_redis_key(mock_cls, client, admin_token):
mock_cls.set_key_value = AsyncMock(return_value="OK")
iid = await _create_redis_instance(client, admin_token)
resp = await client.post(
"/api/v1/redis/" + str(iid) + "/keys",
json={"db": 0, "key": "test_key", "value": "test_value", "type": "string", "ttl": 60},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert resp.json()["message"] == "操作成功"
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
async def test_delete_redis_key(mock_cls, client, admin_token):
mock_cls.del_key = AsyncMock(return_value=1)
iid = await _create_redis_instance(client, admin_token)
resp = await client.delete(
"/api/v1/redis/" + str(iid) + "/keys/test_key?db=0",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert resp.json()["deleted"] == 1
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
async def test_set_expire(mock_cls, client, admin_token):
mock_cls.set_ttl = AsyncMock(return_value=True)
iid = await _create_redis_instance(client, admin_token)
resp = await client.put(
"/api/v1/redis/" + str(iid) + "/keys/test_key/expire",
json={"db": 0, "seconds": 300},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
# ── Command execution ────────────────────────────────────────────
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
async def test_execute_command(mock_cls, client, admin_token):
mock_cls.execute_command = AsyncMock(return_value="PONG")
iid = await _create_redis_instance(client, admin_token)
resp = await client.post(
"/api/v1/redis/" + str(iid) + "/command",
json={"db": 0, "command": "PING"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert resp.json()["output"] == "PONG"
# ── Slowlog & Clients ────────────────────────────────────────────
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
async def test_get_slowlog(mock_cls, client, admin_token):
mock_cls.get_slowlog = AsyncMock(return_value=[
{"id": 1, "timestamp": 1700000000, "duration_us": 5000, "command": "KEYS *"},
])
iid = await _create_redis_instance(client, admin_token)
resp = await client.get(
"/api/v1/redis/" + str(iid) + "/slowlog?count=10",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert len(resp.json()) == 1
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
async def test_get_clients(mock_cls, client, admin_token):
mock_cls.get_clients = AsyncMock(return_value=[
{"id": "1", "addr": "127.0.0.1:54321", "cmd": "ping"},
])
iid = await _create_redis_instance(client, admin_token)
resp = await client.get(
"/api/v1/redis/" + str(iid) + "/clients",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
assert len(resp.json()) == 1
# ── Permission checks ────────────────────────────────────────────
@pytest.mark.asyncio
async def test_create_key_requires_admin_or_dba(client, developer_token, admin_token):
"""Non-admin/dba cannot create keys."""
# Need a redis instance first
iid_data = dict(REDIS_INSTANCE_DATA)
resp = await client.post(
"/api/v1/instances",
json=iid_data,
headers=auth_header(admin_token),
)
iid = resp.json()["id"]
resp = await client.post(
"/api/v1/redis/" + str(iid) + "/keys",
json={"db": 0, "key": "k", "value": "v", "type": "string"},
headers=auth_header(developer_token),
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_flushdb_requires_admin(client, dba_token, admin_token):
"""Non-admin cannot flush DB."""
iid_data = dict(REDIS_INSTANCE_DATA)
resp = await client.post(
"/api/v1/instances",
json=iid_data,
headers=auth_header(admin_token),
)
iid = resp.json()["id"]
resp = await client.delete(
"/api/v1/redis/" + str(iid) + "/flushdb?db=0",
headers=auth_header(dba_token),
)
assert resp.status_code == 403
@pytest.mark.asyncio
@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector")
async def test_flushdb_admin_can(mock_cls, client, admin_token):
"""Admin can flush DB."""
mock_cls.flush_db = AsyncMock(return_value="OK")
iid = await _create_redis_instance(client, admin_token)
resp = await client.delete(
"/api/v1/redis/" + str(iid) + "/flushdb?db=0",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
# ── Non-Redis instance rejection ──────────────────────────────────
@pytest.mark.asyncio
async def test_non_redis_instance_rejected(client, admin_token):
"""A MySQL instance should be rejected on Redis endpoints."""
mysql_data = {
"name": "Test MySQL",
"db_type": "mysql",
"host": "127.0.0.1",
"port": 3306,
"username": "root",
"password": "test123",
"database": "test_db",
"env": "development",
}
resp = await client.post(
"/api/v1/instances",
json=mysql_data,
headers=auth_header(admin_token),
)
iid = resp.json()["id"]
resp = await client.get(
"/api/v1/redis/" + str(iid) + "/info",
headers=auth_header(admin_token),
)
assert resp.status_code == 400
assert "Redis" in resp.json()["detail"]
+4 -5
View File
@@ -15,11 +15,12 @@ import WorkflowPage from '@/pages/WorkflowPage';
import AutoTaskPage from '@/pages/AutoTaskPage'; import AutoTaskPage from '@/pages/AutoTaskPage';
import SettingsPage from '@/pages/SettingsPage'; import SettingsPage from '@/pages/SettingsPage';
import AuditLogsPage from '@/pages/AuditLogsPage'; import AuditLogsPage from '@/pages/AuditLogsPage';
import DataDictionaryPage from '@/pages/DataDictionaryPage';
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘 // 路由守卫:未登录跳转登录页,无权限跳转仪表盘
function RouteGuard() { function RouteGuard() {
const { isLoggedIn, hasPermission } = useAuth(); const { isLoggedIn, hasPermission } = useAuth();
const path = location.pathname.slice(1) || 'dashboard'; const path = location.pathname.split('/')[1] || 'dashboard';
if (!isLoggedIn) { if (!isLoggedIn) {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
@@ -50,10 +51,8 @@ function AppRoutes() {
<Route path="/" element={<Layout />}> <Route path="/" element={<Layout />}>
<Route index element={<Navigate to="/dashboard" replace />} /> <Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} /> <Route path="dashboard" element={<Dashboard />} />
<Route path="mysql" element={<InstancePage />} /> <Route path="instances" element={<InstancePage />} />
<Route path="postgres" element={<InstancePage />} /> <Route path="data-dictionary" element={<DataDictionaryPage />} />
<Route path="redis" element={<InstancePage />} />
<Route path="mongo" element={<InstancePage />} />
<Route path="tickets" element={<TicketsPage />} /> <Route path="tickets" element={<TicketsPage />} />
<Route path="queries" element={<QueriesPage />} /> <Route path="queries" element={<QueriesPage />} />
<Route path="roles" element={<RolesPage />} /> <Route path="roles" element={<RolesPage />} />
+22
View File
@@ -0,0 +1,22 @@
import request from './request';
export var dictionaryApi = {
listDatabases: function (instanceId: number) {
return request.get('/dictionary/instances/' + instanceId + '/databases');
},
listTables: function (instanceId: number, database: string) {
return request.get('/dictionary/instances/' + instanceId + '/tables', {
params: { database: database },
});
},
getTableDetail: function (instanceId: number, tableName: string, database: string) {
return request.get('/dictionary/instances/' + instanceId + '/tables/' + tableName, {
params: { database: database },
});
},
exportDictionary: function (instanceId: number, database: string) {
return request.get('/dictionary/instances/' + instanceId + '/export', {
params: { database: database },
});
},
};
+52
View File
@@ -0,0 +1,52 @@
import request from './request';
export const instanceDetailApi = {
// Databases
listDatabases: (id: number) =>
request.get<any, string[]>('/instances/' + id + '/detail/databases'),
// Tables
listTables: (id: number, database: string) =>
request.get<any, any[]>('/instances/' + id + '/detail/tables', { params: { database: database } }),
// Table Detail
getTableDetail: (id: number, database: string, table: string) =>
request.get<any, any>('/instances/' + id + '/detail/table-detail', { params: { database: database, table: table } }),
// Users
listUsers: (id: number) =>
request.get<any, any[]>('/instances/' + id + '/detail/users'),
// Grants
getGrants: (id: number, user: string, host: string) =>
request.get<any, any>('/instances/' + id + '/detail/grants', { params: { user: user, host: host } }),
// Slow Queries
getSlowQueries: (id: number, database?: string) =>
request.get<any, any[]>('/instances/' + id + '/detail/slow-queries', { params: database ? { database: database } : {} }),
// Processlist
getProcesslist: (id: number) =>
request.get<any, any[]>('/instances/' + id + '/detail/processlist'),
// Kill Process
killProcess: (id: number, pid: number) =>
request.post<any, any>('/instances/' + id + '/detail/processlist/' + pid + '/kill'),
// Status
getStatus: (id: number) =>
request.get<any, any>('/instances/' + id + '/detail/status'),
// Variables
getVariables: (id: number) =>
request.get<any, any[]>('/instances/' + id + '/detail/variables'),
};
export const backupApi = {
list: (instanceId?: number) =>
request.get<any, any[]>('/backups', { params: instanceId ? { instance_id: instanceId } : {} }),
create: (data: any) =>
request.post<any, any>('/backups', data),
get: (id: number) =>
request.get<any, any>('/backups/' + id),
};
+37
View File
@@ -0,0 +1,37 @@
import request from './request';
export var redisApi = {
getInfo: function (id: number) {
return request.get('/redis/' + id + '/info');
},
getDbsize: function (id: number, db: number) {
return request.get('/redis/' + id + '/dbsize', { params: { db: db } });
},
getKeys: function (id: number, params: Record<string, any>) {
return request.get('/redis/' + id + '/keys', { params: params });
},
getKeyDetail: function (id: number, key: string, db: number) {
return request.get('/redis/' + id + '/keys/' + encodeURIComponent(key), { params: { db: db } });
},
createKey: function (id: number, data: Record<string, any>) {
return request.post('/redis/' + id + '/keys', data);
},
deleteKey: function (id: number, key: string, db: number) {
return request.delete('/redis/' + id + '/keys/' + encodeURIComponent(key), { params: { db: db } });
},
setExpire: function (id: number, key: string, data: Record<string, any>) {
return request.put('/redis/' + id + '/keys/' + encodeURIComponent(key) + '/expire', data);
},
flushDb: function (id: number, db: number) {
return request.delete('/redis/' + id + '/flushdb', { params: { db: db } });
},
executeCommand: function (id: number, data: Record<string, any>) {
return request.post('/redis/' + id + '/command', data);
},
getSlowlog: function (id: number, count: number) {
return request.get('/redis/' + id + '/slowlog', { params: { count: count } });
},
getClients: function (id: number) {
return request.get('/redis/' + id + '/clients');
},
};
+46 -57
View File
@@ -2,7 +2,7 @@ import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { import {
LayoutDashboard, Database, Server, Zap, Leaf, LayoutDashboard, Database, Server, Zap, Leaf,
ClipboardList, Search, UserCog, Users, Lock, Shield, GitBranch, Bell, ClipboardList, Search, UserCog, Users, Lock, Shield, GitBranch, Bell,
LogOut, Settings, Clock, Megaphone, Sun, Moon LogOut, Settings, Clock, Megaphone, Sun, Moon, BookOpen
} from 'lucide-react'; } from 'lucide-react';
import { menuSections } from '@/lib/mock'; import { menuSections } from '@/lib/mock';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
@@ -11,7 +11,7 @@ import { useTheme } from '@/hooks/useTheme';
const iconMap: Record<string, React.ElementType> = { const iconMap: Record<string, React.ElementType> = {
LayoutDashboard, Database, Server, Zap, Leaf, LayoutDashboard, Database, Server, Zap, Leaf,
ClipboardList, Search, UserCog, Users, Lock, Shield, GitBranch, Bell, ClipboardList, Search, UserCog, Users, Lock, Shield, GitBranch, Bell,
Settings, Clock, Megaphone, Settings, Clock, Megaphone, BookOpen,
}; };
export default function Layout() { export default function Layout() {
@@ -19,7 +19,7 @@ export default function Layout() {
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const currentPath = location.pathname.slice(1) || 'dashboard'; const currentPath = location.pathname.split('/')[1] || 'dashboard';
const getPageTitle = () => { const getPageTitle = () => {
const item = visibleMenuItems.find(m => m.id === currentPath); const item = visibleMenuItems.find(m => m.id === currentPath);
@@ -29,27 +29,27 @@ export default function Layout() {
return ( return (
<div className="flex h-screen bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] overflow-hidden"> <div className="flex h-screen bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] overflow-hidden">
{/* 侧边栏 */} {/* 侧边栏 */}
<aside className="flex flex-col bg-[var(--dg-bg-secondary)] border-r border-[var(--dg-border)] w-[260px] min-w-[260px]"> <aside className="flex flex-col bg-[var(--dg-bg-secondary)] border-r border-[var(--dg-border)] w-[220px] min-w-[220px]">
{/* Logo */} {/* Logo */}
<div className="h-16 flex items-center px-5 border-b border-[var(--dg-border)]"> <div className="h-14 flex items-center px-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg dg-gradient-br flex items-center justify-center flex-shrink-0"> <div className="w-7 h-7 rounded-lg dg-gradient-br flex items-center justify-center flex-shrink-0">
<Shield className="w-5 h-5 text-white" /> <Shield className="w-4 h-4 text-white" />
</div> </div>
<span className="text-lg font-bold dg-gradient-text whitespace-nowrap"> <span className="text-base font-bold dg-gradient-text whitespace-nowrap">
SQL-Platform SQL-Platform
</span> </span>
</div> </div>
</div> </div>
{/* 菜单 */} {/* 菜单 */}
<nav className="flex-1 overflow-y-auto scrollbar-thin py-3"> <nav className="flex-1 overflow-y-auto scrollbar-thin py-1">
{menuSections.map(section => { {menuSections.map(section => {
const items = visibleMenuItems.filter(item => item.section === section.id); const items = visibleMenuItems.filter(item => item.section === section.id);
if (items.length === 0) return null; if (items.length === 0) return null;
return ( return (
<div key={section.id} className="mb-2"> <div key={section.id} className="mb-1">
<div className="px-5 py-2 text-[11px] font-semibold uppercase tracking-wider text-[var(--dg-text-muted)]"> <div className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--dg-text-muted)]">
{section.label} {section.label}
</div> </div>
{items.map(item => { {items.map(item => {
@@ -58,20 +58,19 @@ export default function Layout() {
return ( return (
<div <div
key={item.id} key={item.id}
className={` onClick={() => navigate('/' + item.id)}
flex items-center mx-3 px-4 py-2.5 rounded-lg cursor-pointer transition-all duration-200 relative className={
${isActive 'flex items-center mx-2 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 relative text-[13px] '
? 'bg-blue-500/15 text-blue-400' + (isActive
: 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]' ? 'bg-blue-500/10 text-blue-400 font-medium'
} : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]')
`} }
onClick={() => navigate(`/${item.id}`)}
> >
{isActive && ( {isActive && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 bg-blue-400 rounded-r" /> <div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-blue-400 rounded-r" />
)} )}
<Icon className="w-[18px] h-[18px] flex-shrink-0" /> <Icon className="w-4 h-4 flex-shrink-0" />
<span className="ml-3 text-sm font-medium">{item.label}</span> <span className="ml-2.5">{item.label}</span>
</div> </div>
); );
})} })}
@@ -79,56 +78,46 @@ export default function Layout() {
); );
})} })}
</nav> </nav>
{/* 底部 */}
<div className="p-4 border-t border-[var(--dg-border)]">
<div className="flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] cursor-pointer transition-colors">
<div className="w-9 h-9 rounded-full dg-gradient-br flex items-center justify-center text-white font-semibold text-sm flex-shrink-0">
{user.avatar}
</div>
<div className="overflow-hidden flex-1">
<div className="text-sm font-medium truncate">{user.username}</div>
<div className="text-xs text-[var(--dg-text-muted)]">{user.roleName}</div>
</div>
</div>
<button
className="mt-2 w-full flex items-center justify-center gap-2 p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] cursor-pointer text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors text-xs"
onClick={() => { logout(); navigate('/login'); }}
>
<LogOut className="w-3.5 h-3.5" /> 退出登录
</button>
</div>
</aside> </aside>
{/* 主内容区 */} {/* 主内容区 */}
<main className="flex-1 flex flex-col min-w-0 overflow-hidden"> <main className="flex-1 flex flex-col min-w-0 overflow-hidden">
{/* 顶部栏 */} {/* 顶部栏 */}
<header className="h-16 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)] flex items-center justify-between px-6 flex-shrink-0"> <header className="h-14 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)] flex items-center justify-between px-5 flex-shrink-0">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<span className="text-[var(--dg-text-muted)]">SQL-Platform</span> <span className="text-[var(--dg-text-muted)]">{getPageTitle()}</span>
<span className="text-[var(--dg-text-muted)]">/</span>
<span className="text-[var(--dg-text-primary)] font-medium">{getPageTitle()}</span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<span className="px-2.5 py-1 rounded-md text-xs font-medium bg-blue-500/15 text-blue-400">
{user.roleName}
</span>
<button <button
onClick={toggleTheme} onClick={toggleTheme}
className="w-9 h-9 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)] transition-colors" className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)] transition-colors"
title={theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'} title={theme === 'dark' ? '浅色模式' : '深色模式'}
> >
{theme === 'dark' ? <Sun className="w-[18px] h-[18px]" /> : <Moon className="w-[18px] h-[18px]" />} {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button> </button>
<button className="w-9 h-9 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)] transition-colors relative"> <button className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)] transition-colors relative">
<Bell className="w-[18px] h-[18px]" /> <Bell className="w-4 h-4" />
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full" /> <span className="absolute top-1 right-1 w-1.5 h-1.5 bg-red-500 rounded-full" />
</button> </button>
<div className="w-px h-5 bg-[var(--dg-border)] mx-1" />
<div className="flex items-center gap-2 pl-1">
<div className="w-7 h-7 rounded-full dg-gradient-br flex items-center justify-center text-white text-xs font-semibold flex-shrink-0">
{user.avatar}
</div>
<span className="text-sm text-[var(--dg-text-secondary)] max-w-[80px] truncate">{user.username}</span>
<button
onClick={() => { logout(); navigate('/login'); }}
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-red-500/10 hover:text-red-400 transition-colors"
title="退出登录"
>
<LogOut className="w-4 h-4" />
</button>
</div>
</div> </div>
</header> </header>
{/* 页面内容 */} {/* 页面内容 */}
<div className="flex-1 overflow-y-auto scrollbar-thin p-6"> <div className="flex-1 overflow-y-auto scrollbar-thin p-5">
<Outlet /> <Outlet />
</div> </div>
</main> </main>
+2 -4
View File
@@ -36,10 +36,8 @@ const roleNameMap: Record<UserRole, string> = {
const routePermissionMap: Record<string, UserRole[]> = { const routePermissionMap: Record<string, UserRole[]> = {
dashboard: ['admin', 'dba', 'developer', 'guest'], dashboard: ['admin', 'dba', 'developer', 'guest'],
mysql: ['admin', 'dba'], instances: ['admin', 'dba'],
postgres: ['admin', 'dba'], 'data-dictionary': ['admin', 'dba', 'developer'],
redis: ['admin', 'dba'],
mongo: ['admin', 'dba'],
tickets: ['admin', 'dba', 'developer'], tickets: ['admin', 'dba', 'developer'],
queries: ['admin', 'dba', 'developer', 'guest'], queries: ['admin', 'dba', 'developer', 'guest'],
roles: ['admin'], roles: ['admin'],
+1 -4
View File
@@ -22,10 +22,7 @@ export const menuSections = [
export const menuItems = [ export const menuItems = [
{ id: 'dashboard', label: '仪表盘', icon: 'LayoutDashboard', section: 'overview', roles: ['admin', 'dba', 'developer', 'guest'] }, { id: 'dashboard', label: '仪表盘', icon: 'LayoutDashboard', section: 'overview', roles: ['admin', 'dba', 'developer', 'guest'] },
{ id: 'mysql', label: 'MySQL', icon: 'Database', section: 'datasource', roles: ['admin', 'dba'] }, { id: 'instances', label: '数据源', icon: 'Database', section: 'datasource', roles: ['admin', 'dba'] },
{ id: 'postgres', label: 'PostgreSQL', icon: 'Server', section: 'datasource', roles: ['admin', 'dba'] },
{ id: 'redis', label: 'Redis', icon: 'Zap', section: 'datasource', roles: ['admin', 'dba'] },
{ id: 'mongo', label: 'MongoDB', icon: 'Leaf', section: 'datasource', roles: ['admin', 'dba'] },
{ id: 'tickets', label: '数据变更', icon: 'ClipboardList', section: 'ticket', roles: ['admin', 'dba', 'developer'] }, { id: 'tickets', label: '数据变更', icon: 'ClipboardList', section: 'ticket', roles: ['admin', 'dba', 'developer'] },
{ id: 'queries', label: '查询审计', icon: 'Search', section: 'ticket', roles: ['admin', 'dba', 'developer', 'guest'] }, { id: 'queries', label: '查询审计', icon: 'Search', section: 'ticket', roles: ['admin', 'dba', 'developer', 'guest'] },
{ id: 'roles', label: '角色权限', icon: 'UserCog', section: 'permission', roles: ['admin'] }, { id: 'roles', label: '角色权限', icon: 'UserCog', section: 'permission', roles: ['admin'] },
+511
View File
@@ -0,0 +1,511 @@
import { useState, useEffect, useCallback } from 'react';
import {
BookOpen, Database, Table2, Search, Download, Columns3, Key, Code2, RefreshCw, ChevronRight,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { instanceApi } from '@/api/instance';
import { dictionaryApi } from '@/api/dictionary';
interface ColumnInfo {
Field: string;
Type: string;
Null: string;
Key: string;
Default: string | null;
Extra: string;
Comment?: string;
}
interface IndexInfo {
name: string;
columns: string[];
non_unique?: number;
index_type?: string;
unique?: number;
definition?: string;
}
interface TableDetail {
columns: ColumnInfo[];
indexes: IndexInfo[];
create_ddl: string;
row_count: number;
}
type TabKey = 'columns' | 'indexes' | 'ddl';
export default function DataDictionaryPage() {
// Left panel state
var selectedInstance = useState<number | null>(null);
var instanceId = selectedInstance[0];
var setInstanceId = selectedInstance[1];
var instanceListState = useState<any[]>([]);
var instances = instanceListState[0];
var setInstances = instanceListState[1];
var selectedDb = useState<string>('');
var currentDb = selectedDb[0];
var setCurrentDb = selectedDb[1];
var databaseListState = useState<string[]>([]);
var databases = databaseListState[0];
var setDatabases = databaseListState[1];
var tableListState = useState<string[]>([]);
var tables = tableListState[0];
var setTables = tableListState[1];
var searchState = useState<string>('');
var searchFilter = searchState[0];
var setSearchFilter = searchState[1];
// Right panel state
var selectedTableState = useState<string>('');
var selectedTable = selectedTableState[0];
var setSelectedTable = selectedTableState[1];
var detailState = useState<TableDetail | null>(null);
var tableDetail = detailState[0];
var setTableDetail = detailState[1];
var tabState = useState<TabKey>('columns');
var activeTab = tabState[0];
var setActiveTab = tabState[1];
var loadingState = useState<boolean>(false);
var loading = loadingState[0];
var setLoading = loadingState[1];
// Load instances
useEffect(function () {
instanceApi.list().then(function (data: any) {
setInstances(Array.isArray(data) ? data : []);
}).catch(function () {
setInstances([]);
});
}, []);
// Load databases when instance changes
var loadDatabases = useCallback(function () {
if (!instanceId) {
setDatabases([]);
setTables([]);
setCurrentDb('');
return;
}
dictionaryApi.listDatabases(instanceId).then(function (data: any) {
var dbList = data && data.databases ? data.databases : [];
setDatabases(dbList);
if (dbList.length > 0) {
setCurrentDb(dbList[0]);
} else {
setCurrentDb('');
}
}).catch(function () {
setDatabases([]);
setCurrentDb('');
});
}, [instanceId]);
useEffect(function () {
loadDatabases();
}, [loadDatabases]);
// Load tables when database changes
useEffect(function () {
if (!instanceId || !currentDb) {
setTables([]);
setSelectedTable('');
setTableDetail(null);
return;
}
dictionaryApi.listTables(instanceId, currentDb).then(function (data: any) {
var tblList = data && data.tables ? data.tables : [];
setTables(tblList);
if (tblList.length > 0) {
setSelectedTable(tblList[0]);
} else {
setSelectedTable('');
setTableDetail(null);
}
}).catch(function () {
setTables([]);
setSelectedTable('');
setTableDetail(null);
});
}, [instanceId, currentDb]);
// Load table detail
useEffect(function () {
if (!instanceId || !currentDb || !selectedTable) {
setTableDetail(null);
return;
}
setLoading(true);
dictionaryApi.getTableDetail(instanceId, selectedTable, currentDb)
.then(function (data: any) {
setTableDetail(data || null);
})
.catch(function () {
setTableDetail(null);
})
.finally(function () {
setLoading(false);
});
}, [instanceId, currentDb, selectedTable]);
// Export handler
function handleExport() {
if (!instanceId || !currentDb) return;
dictionaryApi.exportDictionary(instanceId, currentDb).then(function (data: any) {
var text = JSON.stringify(data, null, 2);
var blob = new Blob([text], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = currentDb + '_dictionary.json';
a.click();
URL.revokeObjectURL(url);
}).catch(function () {
// silently fail
});
}
// Filter tables
var filteredTables = tables.filter(function (t) {
if (!searchFilter) return true;
return t.toLowerCase().indexOf(searchFilter.toLowerCase()) >= 0;
});
// Render tabs
var tabs = [
{ key: 'columns' as TabKey, label: '字段信息', icon: Columns3 },
{ key: 'indexes' as TabKey, label: '索引信息', icon: Key },
{ key: 'ddl' as TabKey, label: 'DDL', icon: Code2 },
];
return (
<div className="space-y-5">
{/* Page header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold flex items-center gap-2">
<BookOpen className="w-5 h-5 text-blue-400" />
数据字典
</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">
浏览数据库结构,查看表字段、索引和DDL定义
</p>
</div>
<button
onClick={handleExport}
disabled={!instanceId || !currentDb}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download className="w-4 h-4" />
导出字典
</button>
</div>
{/* Main layout: left panel + right panel */}
<div className="flex gap-5">
{/* Left panel: w-1/4 */}
<div className="w-1/4 space-y-4">
{/* Instance selector */}
<Card className="dg-card">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Database className="w-4 h-4 text-blue-400" />
选择实例
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<select
value={instanceId || ''}
onChange={function (e) {
var val = e.target.value;
setInstanceId(val ? Number(val) : null);
setSelectedTable('');
setTableDetail(null);
}}
className="w-full px-3 py-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50"
>
<option value="">-- 请选择实例 --</option>
{instances.map(function (inst) {
return (
<option key={inst.id} value={inst.id}>
{inst.name + ' (' + inst.db_type + ')'}
</option>
);
})}
</select>
</CardContent>
</Card>
{/* Database selector */}
<Card className="dg-card">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Database className="w-4 h-4 text-green-400" />
选择数据库
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<select
value={currentDb}
onChange={function (e) {
setCurrentDb(e.target.value);
setSelectedTable('');
setTableDetail(null);
}}
disabled={!instanceId || databases.length === 0}
className="w-full px-3 py-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 disabled:opacity-50"
>
<option value="">-- 请选择数据库 --</option>
{databases.map(function (db) {
return <option key={db} value={db}>{db}</option>;
})}
</select>
</CardContent>
</Card>
{/* Table list with search */}
<Card className="dg-card">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Table2 className="w-4 h-4 text-purple-400" />
表列表
{tables.length > 0 && (
<span className="text-xs text-[var(--dg-text-secondary)] ml-auto">
{'(' + tables.length + ')'}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-2">
{/* Search input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-secondary)]" />
<input
type="text"
value={searchFilter}
onChange={function (e) { setSearchFilter(e.target.value); }}
placeholder="搜索表名..."
className="w-full pl-9 pr-3 py-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50"
/>
</div>
{/* Table list */}
<div className="max-h-[400px] overflow-y-auto space-y-0.5">
{filteredTables.length === 0 ? (
<div className="text-center py-6 text-sm text-[var(--dg-text-secondary)]">
{instanceId && currentDb ? '没有匹配的表' : '请先选择实例和数据库'}
</div>
) : (
filteredTables.map(function (tbl) {
var isSelected = tbl === selectedTable;
return (
<button
key={tbl}
onClick={function () { setSelectedTable(tbl); }}
className={
'w-full text-left px-3 py-2 rounded-md text-sm flex items-center gap-2 transition-colors ' +
(isSelected
? 'bg-blue-500/15 text-blue-400 font-medium'
: 'text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-tertiary)]')
}
>
<ChevronRight className={'w-3 h-3 transition-transform ' + (isSelected ? 'rotate-90' : '')} />
{tbl}
</button>
);
})
)}
</div>
</CardContent>
</Card>
</div>
{/* Right panel: w-3/4 */}
<div className="w-3/4">
{!tableDetail && !loading ? (
<Card className="dg-card h-full">
<CardContent className="flex flex-col items-center justify-center py-20 text-[var(--dg-text-secondary)]">
<Table2 className="w-12 h-12 mb-3 opacity-30" />
<p className="text-sm">选择一个表以查看详细信息</p>
</CardContent>
</Card>
) : (
<Card className="dg-card">
{/* Table header */}
<CardHeader className="pb-3 border-b border-[var(--dg-border)]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CardTitle className="text-base font-semibold">{selectedTable}</CardTitle>
{tableDetail && (
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400">
{'~' + tableDetail.row_count + ' 行'}
</span>
)}
</div>
<button
onClick={function () {
if (instanceId && currentDb && selectedTable) {
setLoading(true);
dictionaryApi.getTableDetail(instanceId, selectedTable, currentDb)
.then(function (data: any) { setTableDetail(data || null); })
.catch(function () { setTableDetail(null); })
.finally(function () { setLoading(false); });
}
}}
className="p-1.5 rounded-md hover:bg-[var(--dg-bg-tertiary)] transition-colors"
title="刷新"
>
<RefreshCw className={'w-4 h-4 ' + (loading ? 'animate-spin' : '')} />
</button>
</div>
</CardHeader>
{/* Tabs */}
<div className="flex border-b border-[var(--dg-border)] px-5">
{tabs.map(function (tab) {
var isActive = activeTab === tab.key;
var Icon = tab.icon;
return (
<button
key={tab.key}
onClick={function () { setActiveTab(tab.key); }}
className={
'flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ' +
(isActive
? 'border-blue-500 text-blue-400'
: 'border-transparent text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]')
}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
);
})}
</div>
{/* Tab content */}
<CardContent className="p-5">
{loading ? (
<div className="flex items-center justify-center py-10">
<RefreshCw className="w-6 h-6 animate-spin text-blue-400" />
<span className="ml-2 text-sm text-[var(--dg-text-secondary)]">加载中...</span>
</div>
) : activeTab === 'columns' ? (
/* Columns tab */
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-4 py-2.5 font-semibold">Field</th>
<th className="text-left px-4 py-2.5 font-semibold">Type</th>
<th className="text-left px-4 py-2.5 font-semibold">Null</th>
<th className="text-left px-4 py-2.5 font-semibold">Key</th>
<th className="text-left px-4 py-2.5 font-semibold">Default</th>
<th className="text-left px-4 py-2.5 font-semibold">Extra</th>
</tr>
</thead>
<tbody>
{(tableDetail && tableDetail.columns ? tableDetail.columns : []).map(function (col) {
return (
<tr key={col.Field} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)] transition-colors">
<td className="px-4 py-2.5 font-mono text-blue-400">{col.Field}</td>
<td className="px-4 py-2.5 font-mono text-green-400">{col.Type}</td>
<td className="px-4 py-2.5">
<span className={
'text-xs px-1.5 py-0.5 rounded ' +
(col.Null === 'YES' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-gray-500/15 text-gray-400')
}>
{col.Null}
</span>
</td>
<td className="px-4 py-2.5">
{col.Key ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-500/15 text-purple-400">
{col.Key}
</span>
) : (
<span className="text-[var(--dg-text-secondary)]">-</span>
)}
</td>
<td className="px-4 py-2.5 font-mono text-[var(--dg-text-secondary)]">
{col.Default !== null ? col.Default : (
<span className="text-gray-500">NULL</span>
)}
</td>
<td className="px-4 py-2.5 text-[var(--dg-text-secondary)]">{col.Extra}</td>
</tr>
);
})}
{(!tableDetail || !tableDetail.columns || tableDetail.columns.length === 0) && (
<tr>
<td colSpan={6} className="text-center py-6 text-[var(--dg-text-secondary)]">
暂无字段信息
</td>
</tr>
)}
</tbody>
</table>
</div>
) : activeTab === 'indexes' ? (
/* Indexes tab */
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
<th className="text-left px-4 py-2.5 font-semibold">Index Name</th>
<th className="text-left px-4 py-2.5 font-semibold">Columns</th>
<th className="text-left px-4 py-2.5 font-semibold">Type</th>
</tr>
</thead>
<tbody>
{(tableDetail && tableDetail.indexes ? tableDetail.indexes : []).map(function (idx, i) {
var cols = idx.columns
? (Array.isArray(idx.columns) ? idx.columns.join(', ') : idx.columns)
: '-';
var idxType = idx.index_type || (idx.unique !== undefined ? (idx.unique ? 'UNIQUE' : 'NORMAL') : '-');
return (
<tr key={idx.name || i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)] transition-colors">
<td className="px-4 py-2.5 font-mono text-blue-400">{idx.name}</td>
<td className="px-4 py-2.5 font-mono">{cols}</td>
<td className="px-4 py-2.5">
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/15 text-green-400">
{idxType}
</span>
</td>
</tr>
);
})}
{(!tableDetail || !tableDetail.indexes || tableDetail.indexes.length === 0) && (
<tr>
<td colSpan={3} className="text-center py-6 text-[var(--dg-text-secondary)]">
暂无索引信息
</td>
</tr>
)}
</tbody>
</table>
</div>
) : (
/* DDL tab */
<div className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] p-4">
<pre className="text-sm font-mono whitespace-pre-wrap text-green-400 leading-relaxed">
{tableDetail && tableDetail.create_ddl ? tableDetail.create_ddl : '-- No DDL available'}
</pre>
</div>
)}
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}
+52 -24
View File
@@ -1,5 +1,4 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { import {
Plus, RefreshCw, Search, Database, Server, Zap, Leaf, Plus, RefreshCw, Search, Database, Server, Zap, Leaf,
Activity, HardDrive, ChevronRight, Trash2, Edit2, Activity, HardDrive, ChevronRight, Trash2, Edit2,
@@ -51,11 +50,11 @@ function getStatusInfo(status: string) {
} }
export default function InstancePage() { export default function InstancePage() {
const { type = 'mysql' } = useParams<{ type: string }>();
const canAdd = useActionPermission('instance.add'); const canAdd = useActionPermission('instance.add');
const canEdit = useActionPermission('instance.edit'); const canEdit = useActionPermission('instance.edit');
const canDelete = useActionPermission('instance.delete'); const canDelete = useActionPermission('instance.delete');
const [activeType, setActiveType] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [filterStatus, setFilterStatus] = useState(''); const [filterStatus, setFilterStatus] = useState('');
const [filterEnv, setFilterEnv] = useState(''); const [filterEnv, setFilterEnv] = useState('');
@@ -65,12 +64,18 @@ export default function InstancePage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const config = dbTypeConfig[type] || dbTypeConfig.mysql; const typeTabs = [
{ key: 'all', label: '全部' },
{ key: 'mysql', label: 'MySQL' },
{ key: 'postgres', label: 'PostgreSQL' },
{ key: 'redis', label: 'Redis' },
{ key: 'mongo', label: 'MongoDB' },
];
const fetchInstances = useCallback(async () => { const fetchInstances = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const res = await instanceApi.list({ db_type: type }); const res = await instanceApi.list({});
const data = Array.isArray(res) ? res : []; const data = Array.isArray(res) ? res : [];
setInstances(data); setInstances(data);
} catch { } catch {
@@ -78,11 +83,12 @@ export default function InstancePage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [type]); }, []);
useEffect(() => { fetchInstances(); }, [fetchInstances]); useEffect(() => { fetchInstances(); }, [fetchInstances]);
const filtered = instances.filter(i => { const filtered = instances.filter(i => {
if (activeType !== 'all' && i.db_type !== activeType) return false;
if (filterStatus && i.status !== filterStatus) return false; if (filterStatus && i.status !== filterStatus) return false;
if (filterEnv && i.env !== filterEnv) return false; if (filterEnv && i.env !== filterEnv) return false;
if (searchQuery && !i.name.includes(searchQuery) && !i.host.includes(searchQuery)) return false; if (searchQuery && !i.name.includes(searchQuery) && !i.host.includes(searchQuery)) return false;
@@ -104,22 +110,37 @@ export default function InstancePage() {
}; };
if (showCreate) { if (showCreate) {
return <CreateInstanceForm defaultType={type} onCancel={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchInstances(); }} />; return <CreateInstanceForm defaultType={activeType === 'all' ? 'mysql' : activeType} onCancel={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchInstances(); }} />;
} }
const selectedConfig = selectedInstance ? (dbTypeConfig[selectedInstance.db_type] || dbTypeConfig.mysql) : dbTypeConfig.mysql;
if (view === 'detail' && selectedInstance) { if (view === 'detail' && selectedInstance) {
return <InstanceDetail instance={selectedInstance} onBack={() => setView('grid')} config={config} canEdit={canEdit} canDelete={canDelete} onDelete={() => { handleDelete(selectedInstance.id); setView('grid'); }} />; return <InstanceDetail instance={selectedInstance} onBack={() => setView('grid')} config={selectedConfig} canEdit={canEdit} canDelete={canDelete} onDelete={() => { handleDelete(selectedInstance.id); setView('grid'); }} />;
} }
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<PageHeader <PageHeader
title={`${config.label} 实例管理`} title="数据源管理"
description={`管理 ${config.label} 数据库实例,监控连接状态`} description="管理所有数据库实例,监控连接状态"
secondaryAction={{ label: '刷新', icon: RefreshCw, onClick: fetchInstances }} secondaryAction={{ label: '刷新', icon: RefreshCw, onClick: fetchInstances }}
action={canAdd ? { label: '添加实例', icon: Plus, onClick: () => setShowCreate(true), permission: 'instance.add' } : undefined} action={canAdd ? { label: '添加实例', icon: Plus, onClick: () => setShowCreate(true), permission: 'instance.add' } : undefined}
/> />
{/* 类型 Tab */}
<div className="flex items-center gap-1 p-1 bg-[var(--dg-bg-tertiary)] rounded-lg w-fit">
{typeTabs.map(tab => (
<button
key={tab.key}
onClick={() => setActiveType(tab.key)}
className={'px-4 py-1.5 rounded-md text-sm font-medium transition-colors ' + (activeType === tab.key ? 'bg-blue-600 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]')}
>
{tab.label}
</button>
))}
</div>
{/* 状态概览 */} {/* 状态概览 */}
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<Card className="dg-card"> <Card className="dg-card">
@@ -168,23 +189,27 @@ export default function InstancePage() {
<EmptyState type="search" /> <EmptyState type="search" />
) : ( ) : (
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
{filtered.map(instance => ( {filtered.map(instance => {
const ic = dbTypeConfig[instance.db_type] || dbTypeConfig.mysql;
const IconComp = ic.icon;
return (
<div key={instance.id} className="dg-card p-5 cursor-pointer dg-card-hover group" onClick={() => { setSelectedInstance(instance); setView('detail'); }}> <div key={instance.id} className="dg-card p-5 cursor-pointer dg-card-hover group" onClick={() => { setSelectedInstance(instance); setView('detail'); }}>
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
<div className={`w-11 h-11 rounded-xl ${config.bg} flex items-center justify-center`}> <div className={'w-11 h-11 rounded-xl ' + ic.bg + ' flex items-center justify-center'}>
<config.icon className={`w-6 h-6 ${config.color}`} /> <IconComp className={'w-6 h-6 ' + ic.color} />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] text-[11px] rounded-md">{envLabel[instance.env] || instance.env}</span> <span className="px-2 py-0.5 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] text-[11px] rounded-md">{envLabel[instance.env] || instance.env}</span>
<div className={`w-2.5 h-2.5 rounded-full ${getStatusInfo(instance.status).online ? 'bg-green-400 shadow-[0_0_0_3px_rgba(63,185,80,0.2)]' : 'bg-red-400'}`} /> <div className={'w-2.5 h-2.5 rounded-full ' + (getStatusInfo(instance.status).online ? 'bg-green-400 shadow-[0_0_0_3px_rgba(63,185,80,0.2)]' : 'bg-red-400')} />
</div> </div>
</div> </div>
<h3 className="text-base font-semibold mb-1">{instance.name}</h3> <h3 className="text-base font-semibold mb-1">{instance.name}</h3>
<p className="text-xs text-[var(--dg-text-muted)] mb-1">{instance.host}:{instance.port}</p> <p className="text-xs text-[var(--dg-text-muted)] mb-1">{instance.host}:{instance.port}</p>
<p className="text-xs text-[var(--dg-text-muted)] mb-3">{config.label} {instance.version || '-'} · {envLabel[instance.env] || instance.env}</p> <p className="text-xs text-[var(--dg-text-muted)] mb-3">{ic.label} {instance.version || ''} {instance.env ? ' · ' + (envLabel[instance.env] || instance.env) : ''}</p>
{instance.description && <p className="text-xs text-[var(--dg-text-secondary)] line-clamp-2">{instance.description}</p>} {instance.description && <p className="text-xs text-[var(--dg-text-secondary)] line-clamp-2">{instance.description}</p>}
</div> </div>
))} );
})}
</div> </div>
)} )}
</div> </div>
@@ -360,11 +385,13 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
const labelCls = 'block text-sm font-medium mb-1.5'; const labelCls = 'block text-sm font-medium mb-1.5';
return ( return (
<div className="max-w-4xl mx-auto space-y-6"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={onCancel}>
<div className="flex items-center justify-between"> <div className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-w-2xl max-h-[85vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<h1 className="text-xl font-semibold">添加数据源</h1> <div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)]">
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]"><XCircle className="w-5 h-5" /></button> <h2 className="text-lg font-semibold">添加数据源</h2>
</div> <button onClick={onCancel} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><XCircle className="w-5 h-5" /></button>
</div>
<div className="p-6 space-y-4">
{/* 数据源信息 */} {/* 数据源信息 */}
<Card className="dg-card"> <Card className="dg-card">
@@ -487,13 +514,14 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
</Card> </Card>
{error && <div className="text-sm text-red-400">{error}</div>} {error && <div className="text-sm text-red-400">{error}</div>}
<div className="flex justify-end gap-3">
<div className="flex justify-end gap-3 pb-6"> <button onClick={onCancel} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">取消</button>
<button onClick={onCancel} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)]">取消</button> <button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm text-white disabled:opacity-50">
<button onClick={handleSubmit} disabled={submitting} className="px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 disabled:opacity-50">
{submitting ? '创建中...' : '添加数据源'} {submitting ? '创建中...' : '添加数据源'}
</button> </button>
</div> </div>
</div>
</div>
</div> </div>
); );
} }