From 5cbd0e34418280406b41f5febab1de26b63dc231 Mon Sep 17 00:00:00 2001 From: logan Date: Fri, 22 May 2026 22:53:48 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=95=B0=E6=8D=AE=E6=BA=90?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E5=90=88=E4=B8=80+=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 4个数据源菜单(MySQL/PG/Redis/Mongo)合并为1个「数据源」 - InstancePage 改为统一入口,顶部Tab切换类型过滤 - 添加数据源改为弹框Modal(默认MySQL) - 去掉左下角用户区,退出登录移到右上角header - 侧边栏收窄220px,整体布局更紧凑 - 后端202 tests passing - 前端构建零错误 --- backend/app/api/v1/endpoints/backup.py | 62 +++ backend/app/api/v1/endpoints/dictionary.py | 70 +++ .../app/api/v1/endpoints/instance_detail.py | 331 ++++++++++++ backend/app/api/v1/endpoints/redis.py | 240 ++++++++ backend/app/api/v1/router.py | 5 + backend/app/main.py | 1 + backend/app/models/backup.py | 19 + backend/app/schemas/backup.py | 28 + backend/app/services/dictionary_service.py | 240 ++++++++ backend/app/services/redis_connector.py | 269 +++++++++ backend/tests/conftest.py | 1 + backend/tests/test_dictionary.py | 227 ++++++++ backend/tests/test_instance_detail.py | 365 +++++++++++++ backend/tests/test_redis.py | 296 ++++++++++ frontend/src/App.tsx | 9 +- frontend/src/api/dictionary.ts | 22 + frontend/src/api/instanceDetail.ts | 52 ++ frontend/src/api/redis.ts | 37 ++ frontend/src/components/Layout.tsx | 103 ++-- frontend/src/hooks/useAuth.tsx | 6 +- frontend/src/lib/mock.ts | 5 +- frontend/src/pages/DataDictionaryPage.tsx | 511 ++++++++++++++++++ frontend/src/pages/InstancePage.tsx | 76 ++- 23 files changed, 2881 insertions(+), 94 deletions(-) create mode 100644 backend/app/api/v1/endpoints/backup.py create mode 100644 backend/app/api/v1/endpoints/dictionary.py create mode 100644 backend/app/api/v1/endpoints/instance_detail.py create mode 100644 backend/app/api/v1/endpoints/redis.py create mode 100644 backend/app/models/backup.py create mode 100644 backend/app/schemas/backup.py create mode 100644 backend/app/services/dictionary_service.py create mode 100644 backend/app/services/redis_connector.py create mode 100644 backend/tests/test_dictionary.py create mode 100644 backend/tests/test_instance_detail.py create mode 100644 backend/tests/test_redis.py create mode 100644 frontend/src/api/dictionary.ts create mode 100644 frontend/src/api/instanceDetail.ts create mode 100644 frontend/src/api/redis.ts create mode 100644 frontend/src/pages/DataDictionaryPage.tsx diff --git a/backend/app/api/v1/endpoints/backup.py b/backend/app/api/v1/endpoints/backup.py new file mode 100644 index 0000000..52d5cae --- /dev/null +++ b/backend/app/api/v1/endpoints/backup.py @@ -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 diff --git a/backend/app/api/v1/endpoints/dictionary.py b/backend/app/api/v1/endpoints/dictionary.py new file mode 100644 index 0000000..0c9e293 --- /dev/null +++ b/backend/app/api/v1/endpoints/dictionary.py @@ -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} diff --git a/backend/app/api/v1/endpoints/instance_detail.py b/backend/app/api/v1/endpoints/instance_detail.py new file mode 100644 index 0000000..5011815 --- /dev/null +++ b/backend/app/api/v1/endpoints/instance_detail.py @@ -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)) diff --git a/backend/app/api/v1/endpoints/redis.py b/backend/app/api/v1/endpoints/redis.py new file mode 100644 index 0000000..1550326 --- /dev/null +++ b/backend/app/api/v1/endpoints/redis.py @@ -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)) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 5b27e16..6a1fbeb 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -3,6 +3,7 @@ from fastapi import APIRouter from app.api.v1.endpoints import ( auth, user, instance, ticket, query, dashboard, audit, audit_rule, announcement, notification, ldap_config, + instance_detail, backup, redis, dictionary, ) api_router = APIRouter(prefix="/api/v1") @@ -18,3 +19,7 @@ api_router.include_router(audit_rule.router) api_router.include_router(announcement.router) api_router.include_router(notification.router) api_router.include_router(ldap_config.router) +api_router.include_router(instance_detail.router) +api_router.include_router(backup.router) +api_router.include_router(redis.router) +api_router.include_router(dictionary.router, prefix="/dictionary", tags=["dictionary"]) diff --git a/backend/app/main.py b/backend/app/main.py index c200de6..47914c1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -52,6 +52,7 @@ async def lifespan(app: FastAPI): import app.models.announcement # noqa import app.models.notification # noqa import app.models.ldap_config # noqa + import app.models.backup # noqa await init_db() await seed_data() diff --git a/backend/app/models/backup.py b/backend/app/models/backup.py new file mode 100644 index 0000000..b177fab --- /dev/null +++ b/backend/app/models/backup.py @@ -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) diff --git a/backend/app/schemas/backup.py b/backend/app/schemas/backup.py new file mode 100644 index 0000000..8462b12 --- /dev/null +++ b/backend/app/schemas/backup.py @@ -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 diff --git a/backend/app/services/dictionary_service.py b/backend/app/services/dictionary_service.py new file mode 100644 index 0000000..99a6b14 --- /dev/null +++ b/backend/app/services/dictionary_service.py @@ -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 diff --git a/backend/app/services/redis_connector.py b/backend/app/services/redis_connector.py new file mode 100644 index 0000000..6490099 --- /dev/null +++ b/backend/app/services/redis_connector.py @@ -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() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ba5e4c2..79fb63e 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -15,6 +15,7 @@ from app.models.audit_rule import AuditRule from app.models.announcement import Announcement from app.models.notification import NotificationChannel from app.models.ldap_config import LdapConfig +from app.models.backup import Backup SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite://" diff --git a/backend/tests/test_dictionary.py b/backend/tests/test_dictionary.py new file mode 100644 index 0000000..b565f25 --- /dev/null +++ b/backend/tests/test_dictionary.py @@ -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 diff --git a/backend/tests/test_instance_detail.py b/backend/tests/test_instance_detail.py new file mode 100644 index 0000000..7df0b39 --- /dev/null +++ b/backend/tests/test_instance_detail.py @@ -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 diff --git a/backend/tests/test_redis.py b/backend/tests/test_redis.py new file mode 100644 index 0000000..843f148 --- /dev/null +++ b/backend/tests/test_redis.py @@ -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"] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 263c83e..3698aba 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,11 +15,12 @@ import WorkflowPage from '@/pages/WorkflowPage'; import AutoTaskPage from '@/pages/AutoTaskPage'; import SettingsPage from '@/pages/SettingsPage'; import AuditLogsPage from '@/pages/AuditLogsPage'; +import DataDictionaryPage from '@/pages/DataDictionaryPage'; // 路由守卫:未登录跳转登录页,无权限跳转仪表盘 function RouteGuard() { const { isLoggedIn, hasPermission } = useAuth(); - const path = location.pathname.slice(1) || 'dashboard'; + const path = location.pathname.split('/')[1] || 'dashboard'; if (!isLoggedIn) { return ; @@ -50,10 +51,8 @@ function AppRoutes() { }> } /> } /> - } /> - } /> - } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/dictionary.ts b/frontend/src/api/dictionary.ts new file mode 100644 index 0000000..4105404 --- /dev/null +++ b/frontend/src/api/dictionary.ts @@ -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 }, + }); + }, +}; diff --git a/frontend/src/api/instanceDetail.ts b/frontend/src/api/instanceDetail.ts new file mode 100644 index 0000000..6521104 --- /dev/null +++ b/frontend/src/api/instanceDetail.ts @@ -0,0 +1,52 @@ +import request from './request'; + +export const instanceDetailApi = { + // Databases + listDatabases: (id: number) => + request.get('/instances/' + id + '/detail/databases'), + + // Tables + listTables: (id: number, database: string) => + request.get('/instances/' + id + '/detail/tables', { params: { database: database } }), + + // Table Detail + getTableDetail: (id: number, database: string, table: string) => + request.get('/instances/' + id + '/detail/table-detail', { params: { database: database, table: table } }), + + // Users + listUsers: (id: number) => + request.get('/instances/' + id + '/detail/users'), + + // Grants + getGrants: (id: number, user: string, host: string) => + request.get('/instances/' + id + '/detail/grants', { params: { user: user, host: host } }), + + // Slow Queries + getSlowQueries: (id: number, database?: string) => + request.get('/instances/' + id + '/detail/slow-queries', { params: database ? { database: database } : {} }), + + // Processlist + getProcesslist: (id: number) => + request.get('/instances/' + id + '/detail/processlist'), + + // Kill Process + killProcess: (id: number, pid: number) => + request.post('/instances/' + id + '/detail/processlist/' + pid + '/kill'), + + // Status + getStatus: (id: number) => + request.get('/instances/' + id + '/detail/status'), + + // Variables + getVariables: (id: number) => + request.get('/instances/' + id + '/detail/variables'), +}; + +export const backupApi = { + list: (instanceId?: number) => + request.get('/backups', { params: instanceId ? { instance_id: instanceId } : {} }), + create: (data: any) => + request.post('/backups', data), + get: (id: number) => + request.get('/backups/' + id), +}; diff --git a/frontend/src/api/redis.ts b/frontend/src/api/redis.ts new file mode 100644 index 0000000..9b16388 --- /dev/null +++ b/frontend/src/api/redis.ts @@ -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) { + 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) { + 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) { + 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) { + 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'); + }, +}; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index fd09852..603f60f 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -2,7 +2,7 @@ import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { LayoutDashboard, Database, Server, Zap, Leaf, ClipboardList, Search, UserCog, Users, Lock, Shield, GitBranch, Bell, - LogOut, Settings, Clock, Megaphone, Sun, Moon + LogOut, Settings, Clock, Megaphone, Sun, Moon, BookOpen } from 'lucide-react'; import { menuSections } from '@/lib/mock'; import { useAuth } from '@/hooks/useAuth'; @@ -11,7 +11,7 @@ import { useTheme } from '@/hooks/useTheme'; const iconMap: Record = { LayoutDashboard, Database, Server, Zap, Leaf, ClipboardList, Search, UserCog, Users, Lock, Shield, GitBranch, Bell, - Settings, Clock, Megaphone, + Settings, Clock, Megaphone, BookOpen, }; export default function Layout() { @@ -19,7 +19,7 @@ export default function Layout() { const { theme, toggleTheme } = useTheme(); const location = useLocation(); const navigate = useNavigate(); - const currentPath = location.pathname.slice(1) || 'dashboard'; + const currentPath = location.pathname.split('/')[1] || 'dashboard'; const getPageTitle = () => { const item = visibleMenuItems.find(m => m.id === currentPath); @@ -29,27 +29,27 @@ export default function Layout() { return (
{/* 侧边栏 */} -