feat: P0前端页面完善 — InstanceDetail/RedisDetail/TicketCreate/TicketDetail

- InstanceDetailPage: 概览/表管理/SQL查询/用户权限/慢查询/进程列表 6个Tab
- RedisDetailPage: 键管理/监控信息/命令行/性能分析 4个Tab + 新增键弹框
- TicketCreatePage: 工单创建向导(选择实例/填写SQL/审核规则检查)
- TicketDetailPage: 工单详情+审批/拒绝/执行/回滚操作
- App.tsx 路由注册: instances/:id, redis/:id, tickets/create, tickets/:id
- API方法名对齐: listDatabases/listTables/listUsers
- 前端构建零错误, 后端202测试通过
This commit is contained in:
logan committed 2026-05-22 23:10:14 +08:00
1 parent 5cbd0e3441
commit dfdac3ad37
7 files changed
+1415 -8

No files matched your search

+197
View File
@@ -2,6 +2,7 @@ from typing import Optional, List, Dict, Any
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,6 +17,12 @@ from app.services.audit_engine import SQLAuditEngine
from app.services.audit_log_service import AuditLogService
import json
class AuditRequest(BaseModel):
sql_content: str
instance_id: int
database: str = ""
router = APIRouter(prefix="/tickets", tags=["工单管理"])
@@ -164,6 +171,33 @@ async def ticket_statistics(
}
@router.post("/audit")
async def audit_sql(
data: AuditRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Verify instance exists
inst_result = await db.execute(select(Instance).where(Instance.id == data.instance_id))
instance = inst_result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=400, detail="数据源不存在")
env = "production" if instance.env == "production" else "development"
engine = SQLAuditEngine(env=env)
report = engine.audit(data.sql_content)
return {
"score": report.score,
"passed": report.passed,
"has_warnings": report.has_warnings,
"findings": [
{"rule": f.rule, "level": f.level.value, "message": f.message}
for f in report.findings
],
}
@router.get("/{ticket_id}")
async def get_ticket(
ticket_id: int,
@@ -216,6 +250,169 @@ async def review_ticket(
return await _enrich_ticket(ticket, db)
@router.get("/{ticket_id}/timeline")
async def get_ticket_timeline(
ticket_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="工单不存在")
if current_user.role == "developer" and ticket.applicant_id != current_user.id:
raise HTTPException(status_code=403, detail="无权查看该工单")
# Fetch audit logs for this ticket
from app.models.audit_log import AuditLog
log_stmt = (
select(AuditLog)
.where(AuditLog.target_type == "ticket", AuditLog.target_id == ticket_id)
.order_by(AuditLog.created_at.asc())
)
log_result = await db.execute(log_stmt)
logs = log_result.scalars().all()
timeline = []
# Creation event
applicant_name = "未知"
if ticket.applicant_id:
r = await db.execute(select(User).where(User.id == ticket.applicant_id))
applicant = r.scalar_one_or_none()
if applicant:
applicant_name = applicant.display_name
timeline.append({
"action": "CREATE_TICKET",
"label": "创建工单",
"user": applicant_name,
"time": ticket.create_time.isoformat() if ticket.create_time else None,
"detail": "提交 SQL 变更工单",
})
# From audit logs
for log in logs:
if log.action == "CREATE_TICKET":
continue # already handled
r = await db.execute(select(User).where(User.id == log.user_id))
u = r.scalar_one_or_none()
uname = u.display_name if u else "未知"
label_map = {
"REVIEW_TICKET": "审核工单",
"EXECUTE_TICKET": "执行工单",
"CANCEL_TICKET": "取消工单",
"ROLLBACK_TICKET": "回滚工单",
}
timeline.append({
"action": log.action,
"label": label_map.get(log.action, log.action),
"user": uname,
"time": log.created_at.isoformat() if log.created_at else None,
"detail": log.detail,
})
# Status-based synthetic events if no log entries cover them
if ticket.review_time:
has_review = any(t["action"] == "REVIEW_TICKET" for t in timeline)
if not has_review:
approver_name = "未知"
if ticket.approver_id:
r = await db.execute(select(User).where(User.id == ticket.approver_id))
a = r.scalar_one_or_none()
if a:
approver_name = a.display_name
timeline.append({
"action": "REVIEW_TICKET",
"label": "审核工单",
"user": approver_name,
"time": ticket.review_time.isoformat() if ticket.review_time else None,
"detail": "工单状态变更为 " + str(ticket.status),
})
if ticket.execute_time:
has_exec = any(t["action"] == "EXECUTE_TICKET" for t in timeline)
if not has_exec:
timeline.append({
"action": "EXECUTE_TICKET",
"label": "执行工单",
"user": "系统",
"time": ticket.execute_time.isoformat() if ticket.execute_time else None,
"detail": ticket.execute_result or "执行完成",
})
return {"ticket_id": ticket_id, "timeline": timeline}
@router.post("/{ticket_id}/cancel")
async def cancel_ticket(
ticket_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="工单不存在")
# Only applicant or admin can cancel
if ticket.applicant_id != current_user.id and current_user.role != "admin":
raise HTTPException(status_code=403, detail="只有申请人或管理员可以取消工单")
if ticket.status not in ("pending", "reviewing"):
raise HTTPException(status_code=400, detail="只能取消待审批或审批中的工单")
ticket.status = "rejected"
ticket.audit_result = "工单被取消"
await AuditLogService.log(
db, user_id=current_user.id, action="CANCEL_TICKET",
target_type="ticket", target_id=ticket.id,
detail="取消工单",
)
await db.commit()
await db.refresh(ticket)
return await _enrich_ticket(ticket, db)
@router.post("/{ticket_id}/rollback")
async def rollback_ticket(
ticket_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="工单不存在")
if ticket.status not in ("success", "failed"):
raise HTTPException(status_code=400, detail="只能回滚已执行(成功或失败)的工单")
# Simplified rollback — return info message
rollback_sql = ticket.rollback_sql or "未提供回滚 SQL"
await AuditLogService.log(
db, user_id=current_user.id, action="ROLLBACK_TICKET",
target_type="ticket", target_id=ticket.id,
detail="请求回滚工单",
)
ticket.status = "rollback"
await db.commit()
await db.refresh(ticket)
return {
"message": "回滚请求已记录,请手动执行以下回滚 SQL",
"ticket_id": ticket_id,
"rollback_sql": rollback_sql,
"status": ticket.status,
}
@router.post("/{ticket_id}/execute")
async def execute_ticket(
ticket_id: int,