Files
opsxsql/backend/app/api/v1/endpoints/instance_detail.py
T
logan 43d1f3a234 feat: 工单权限过滤+仪表盘优化+LDAP登录+审批流程+待处理工单
后端:
- ticket_statistics/list_tickets 按用户身份过滤(admin全部, dba/auditor审批相关, developer自己的)
- 新增 /dashboard/my-tickets 接口(按角色返回待处理工单)
- auth.py 本地优先+LDAP fallback 登录认证
- ldap_config.py LDAPS 证书验证修复
- instance_detail.py developer/auditor 权限修复

前端:
- Dashboard 仪表盘: 3核心大数字指标+公告+我的待处理工单
- 删除 ECharts 趋势图(无真实数据)
- TicketDetailPage 审批流程时间线展示
- TicketsPage 工单统计按用户过滤+关联列(我提交的/待我审批)
- types 新增 TicketApproval 等类型定义
2026-05-25 20:47:15 +08:00

349 lines
12 KiB
Python

"""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.core.permissions import check_instance_access
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, params: Optional[list] = 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, params)
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", "developer", "auditor")),
):
instance = await _get_decrypted_instance(db, instance_id)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
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", "developer", "auditor")),
):
instance = await _get_decrypted_instance(db, instance_id)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
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))
# ---- DB Schema (for autocomplete) ----
@router.get("/{instance_id}/detail/db-schema")
async def get_db_schema(
instance_id: int,
database: str = Query(..., description="数据库名"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba", "developer")),
):
"""一次性返回整个库的 {table: [columns]} 结构,用于前端 SQL 自动补全"""
instance = await _get_decrypted_instance(db, instance_id)
await check_instance_access(current_user, instance, db)
if instance.db_type != "mysql":
raise HTTPException(status_code=400, detail="仅支持MySQL实例")
try:
rows = await _mysql_query(
instance,
"SELECT TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = %s ORDER BY TABLE_NAME, ORDINAL_POSITION",
database=database,
params=[database],
)
schema: dict[str, list[str]] = {}
for row in rows:
tbl = row["TABLE_NAME"]
col = row["COLUMN_NAME"]
schema.setdefault(tbl, []).append(col)
return schema
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", "developer", "auditor")),
):
instance = await _get_decrypted_instance(db, instance_id)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
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)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
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)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
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))
# ---- 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)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
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)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
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)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
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)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
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))