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